ophyd-async 0.2.0__py3-none-any.whl → 0.3.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 (79) hide show
  1. ophyd_async/__init__.py +1 -4
  2. ophyd_async/_version.py +2 -2
  3. ophyd_async/core/__init__.py +52 -19
  4. ophyd_async/core/_providers.py +38 -5
  5. ophyd_async/core/async_status.py +86 -40
  6. ophyd_async/core/detector.py +214 -72
  7. ophyd_async/core/device.py +91 -50
  8. ophyd_async/core/device_save_loader.py +96 -23
  9. ophyd_async/core/flyer.py +32 -246
  10. ophyd_async/core/mock_signal_backend.py +82 -0
  11. ophyd_async/core/mock_signal_utils.py +145 -0
  12. ophyd_async/core/signal.py +225 -58
  13. ophyd_async/core/signal_backend.py +8 -5
  14. ophyd_async/core/{sim_signal_backend.py → soft_signal_backend.py} +51 -49
  15. ophyd_async/core/standard_readable.py +212 -23
  16. ophyd_async/core/utils.py +123 -30
  17. ophyd_async/epics/_backend/_aioca.py +42 -44
  18. ophyd_async/epics/_backend/_p4p.py +96 -52
  19. ophyd_async/epics/_backend/common.py +25 -0
  20. ophyd_async/epics/areadetector/__init__.py +8 -4
  21. ophyd_async/epics/areadetector/aravis.py +63 -0
  22. ophyd_async/epics/areadetector/controllers/__init__.py +2 -1
  23. ophyd_async/epics/areadetector/controllers/ad_sim_controller.py +1 -1
  24. ophyd_async/epics/areadetector/controllers/aravis_controller.py +78 -0
  25. ophyd_async/epics/areadetector/controllers/kinetix_controller.py +49 -0
  26. ophyd_async/epics/areadetector/controllers/pilatus_controller.py +37 -25
  27. ophyd_async/epics/areadetector/controllers/vimba_controller.py +66 -0
  28. ophyd_async/epics/areadetector/drivers/__init__.py +6 -0
  29. ophyd_async/epics/areadetector/drivers/ad_base.py +8 -12
  30. ophyd_async/epics/areadetector/drivers/aravis_driver.py +38 -0
  31. ophyd_async/epics/areadetector/drivers/kinetix_driver.py +27 -0
  32. ophyd_async/epics/areadetector/drivers/pilatus_driver.py +8 -5
  33. ophyd_async/epics/areadetector/drivers/vimba_driver.py +63 -0
  34. ophyd_async/epics/areadetector/kinetix.py +46 -0
  35. ophyd_async/epics/areadetector/pilatus.py +45 -0
  36. ophyd_async/epics/areadetector/single_trigger_det.py +14 -6
  37. ophyd_async/epics/areadetector/utils.py +2 -12
  38. ophyd_async/epics/areadetector/vimba.py +43 -0
  39. ophyd_async/epics/areadetector/writers/_hdffile.py +21 -7
  40. ophyd_async/epics/areadetector/writers/hdf_writer.py +32 -17
  41. ophyd_async/epics/areadetector/writers/nd_file_hdf.py +19 -18
  42. ophyd_async/epics/areadetector/writers/nd_plugin.py +15 -7
  43. ophyd_async/epics/demo/__init__.py +75 -49
  44. ophyd_async/epics/motion/motor.py +67 -53
  45. ophyd_async/epics/pvi/__init__.py +3 -0
  46. ophyd_async/epics/pvi/pvi.py +318 -0
  47. ophyd_async/epics/signal/__init__.py +8 -3
  48. ophyd_async/epics/signal/signal.py +26 -9
  49. ophyd_async/log.py +130 -0
  50. ophyd_async/panda/__init__.py +21 -5
  51. ophyd_async/panda/_common_blocks.py +49 -0
  52. ophyd_async/panda/_hdf_panda.py +48 -0
  53. ophyd_async/panda/_panda_controller.py +37 -0
  54. ophyd_async/panda/_trigger.py +39 -0
  55. ophyd_async/panda/_utils.py +15 -0
  56. ophyd_async/panda/writers/__init__.py +3 -0
  57. ophyd_async/panda/writers/_hdf_writer.py +220 -0
  58. ophyd_async/panda/writers/_panda_hdf_file.py +58 -0
  59. ophyd_async/plan_stubs/__init__.py +13 -0
  60. ophyd_async/plan_stubs/ensure_connected.py +22 -0
  61. ophyd_async/plan_stubs/fly.py +149 -0
  62. ophyd_async/protocols.py +126 -0
  63. ophyd_async/sim/__init__.py +11 -0
  64. ophyd_async/sim/demo/__init__.py +3 -0
  65. ophyd_async/sim/demo/sim_motor.py +103 -0
  66. ophyd_async/sim/pattern_generator.py +318 -0
  67. ophyd_async/sim/sim_pattern_detector_control.py +55 -0
  68. ophyd_async/sim/sim_pattern_detector_writer.py +34 -0
  69. ophyd_async/sim/sim_pattern_generator.py +37 -0
  70. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/METADATA +31 -70
  71. ophyd_async-0.3.0.dist-info/RECORD +86 -0
  72. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/WHEEL +1 -1
  73. ophyd_async/epics/signal/pvi_get.py +0 -22
  74. ophyd_async/panda/panda.py +0 -294
  75. ophyd_async-0.2.0.dist-info/RECORD +0 -53
  76. /ophyd_async/panda/{table.py → _table.py} +0 -0
  77. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/LICENSE +0 -0
  78. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/entry_points.txt +0 -0
  79. {ophyd_async-0.2.0.dist-info → ophyd_async-0.3.0.dist-info}/top_level.txt +0 -0
@@ -1,27 +1,46 @@
1
1
  """Module which defines abstract classes to work with detectors"""
2
+
2
3
  import asyncio
4
+ import time
3
5
  from abc import ABC, abstractmethod
6
+ from dataclasses import dataclass
4
7
  from enum import Enum
5
- from typing import AsyncIterator, Dict, Optional, Sequence
8
+ from typing import (
9
+ AsyncGenerator,
10
+ AsyncIterator,
11
+ Callable,
12
+ Dict,
13
+ Generic,
14
+ List,
15
+ Optional,
16
+ Sequence,
17
+ TypeVar,
18
+ )
6
19
 
7
20
  from bluesky.protocols import (
8
- Asset,
9
- Configurable,
10
- Descriptor,
11
- Readable,
21
+ Collectable,
22
+ DataKey,
23
+ Flyable,
24
+ Preparable,
12
25
  Reading,
13
26
  Stageable,
27
+ StreamAsset,
14
28
  Triggerable,
15
- WritesExternalAssets,
29
+ WritesStreamAssets,
16
30
  )
17
31
 
18
- from .async_status import AsyncStatus
32
+ from ophyd_async.protocols import AsyncConfigurable, AsyncReadable
33
+
34
+ from .async_status import AsyncStatus, WatchableAsyncStatus
19
35
  from .device import Device
20
- from .signal import SignalR
21
- from .utils import DEFAULT_TIMEOUT, merge_gathered_dicts
36
+ from .utils import DEFAULT_TIMEOUT, WatcherUpdate, merge_gathered_dicts
37
+
38
+ T = TypeVar("T")
22
39
 
23
40
 
24
41
  class DetectorTrigger(str, Enum):
42
+ """Type of mechanism for triggering a detector to take frames"""
43
+
25
44
  #: Detector generates internal trigger for given rate
26
45
  internal = "internal"
27
46
  #: Expect a series of arbitrary length trigger signals
@@ -32,7 +51,26 @@ class DetectorTrigger(str, Enum):
32
51
  variable_gate = "variable_gate"
33
52
 
34
53
 
54
+ @dataclass(frozen=True)
55
+ class TriggerInfo:
56
+ """Minimal set of information required to setup triggering on a detector"""
57
+
58
+ #: Number of triggers that will be sent
59
+ num: int
60
+ #: Sort of triggers that will be sent
61
+ trigger: DetectorTrigger
62
+ #: What is the minimum deadtime between triggers
63
+ deadtime: float
64
+ #: What is the maximum high time of the triggers
65
+ livetime: float
66
+
67
+
35
68
  class DetectorControl(ABC):
69
+ """
70
+ Classes implementing this interface should hold the logic for
71
+ arming and disarming a detector
72
+ """
73
+
36
74
  @abstractmethod
37
75
  def get_deadtime(self, exposure: float) -> float:
38
76
  """For a given exposure, how long should the time between exposures be"""
@@ -40,23 +78,38 @@ class DetectorControl(ABC):
40
78
  @abstractmethod
41
79
  async def arm(
42
80
  self,
81
+ num: int,
43
82
  trigger: DetectorTrigger = DetectorTrigger.internal,
44
- num: int = 0,
45
83
  exposure: Optional[float] = None,
46
84
  ) -> AsyncStatus:
47
- """Arm the detector and return AsyncStatus.
85
+ """
86
+ Arm detector, do all necessary steps to prepare detector for triggers.
87
+
88
+ Args:
89
+ num: Expected number of frames
90
+ trigger: Type of trigger for which to prepare the detector. Defaults to
91
+ DetectorTrigger.internal.
92
+ exposure: Exposure time with which to set up the detector. Defaults to None
93
+ if not applicable or the detector is expected to use its previously-set
94
+ exposure time.
48
95
 
49
- Awaiting the return value will wait for num frames to be written.
96
+ Returns:
97
+ AsyncStatus: Status representing the arm operation. This function returning
98
+ represents the start of the arm. The returned status completing means
99
+ the detector is now armed.
50
100
  """
51
101
 
52
102
  @abstractmethod
53
103
  async def disarm(self):
54
- """Disarm the detector"""
104
+ """Disarm the detector, return detector to an idle state"""
55
105
 
56
106
 
57
107
  class DetectorWriter(ABC):
108
+ """Logic for making a detector write data to somewhere persistent
109
+ (e.g. an HDF5 file)"""
110
+
58
111
  @abstractmethod
59
- async def open(self, multiplier: int = 1) -> Dict[str, Descriptor]:
112
+ async def open(self, multiplier: int = 1) -> Dict[str, DataKey]:
60
113
  """Open writer and wait for it to be ready for data.
61
114
 
62
115
  Args:
@@ -68,65 +121,78 @@ class DetectorWriter(ABC):
68
121
  """
69
122
 
70
123
  @abstractmethod
71
- async def wait_for_index(
72
- self, index: int, timeout: Optional[float] = DEFAULT_TIMEOUT
73
- ) -> None:
74
- """Wait until a specific index is ready to be collected"""
124
+ def observe_indices_written(
125
+ self, timeout=DEFAULT_TIMEOUT
126
+ ) -> AsyncGenerator[int, None]:
127
+ """Yield the index of each frame (or equivalent data point) as it is written"""
75
128
 
76
129
  @abstractmethod
77
130
  async def get_indices_written(self) -> int:
78
131
  """Get the number of indices written"""
79
132
 
80
133
  @abstractmethod
81
- def collect_stream_docs(self, indices_written: int) -> AsyncIterator[Asset]:
134
+ def collect_stream_docs(self, indices_written: int) -> AsyncIterator[StreamAsset]:
82
135
  """Create Stream docs up to given number written"""
83
136
 
84
137
  @abstractmethod
85
138
  async def close(self) -> None:
86
- """Close writer and wait for it to be finished"""
139
+ """Close writer, blocks until I/O is complete"""
87
140
 
88
141
 
89
142
  class StandardDetector(
90
143
  Device,
91
144
  Stageable,
92
- Configurable,
93
- Readable,
145
+ AsyncConfigurable,
146
+ AsyncReadable,
94
147
  Triggerable,
95
- WritesExternalAssets,
148
+ Preparable,
149
+ Flyable,
150
+ Collectable,
151
+ WritesStreamAssets,
152
+ Generic[T],
96
153
  ):
97
- """Detector with useful default behaviour.
98
-
99
- Must be supplied instances of classes that inherit from DetectorControl and
100
- DetectorData, to dictate how the detector will be controlled (i.e. arming and
101
- disarming) as well as how the detector data will be written (i.e. opening and
102
- closing the writer, and handling data writing indices).
103
-
104
- NOTE: only for step-scans.
154
+ """
155
+ Useful detector base class for step and fly scanning detectors.
156
+ Aggregates controller and writer logic together.
105
157
  """
106
158
 
107
159
  def __init__(
108
160
  self,
109
161
  controller: DetectorControl,
110
162
  writer: DetectorWriter,
111
- config_sigs: Sequence[SignalR] = (),
163
+ config_sigs: Sequence[AsyncReadable] = (),
112
164
  name: str = "",
113
165
  writer_timeout: float = DEFAULT_TIMEOUT,
114
166
  ) -> None:
115
167
  """
116
- Parameters
117
- ----------
118
- control:
119
- instance of class which inherits from :class:`DetectorControl`
120
- data:
121
- instance of class which inherits from :class:`DetectorData`
122
- name:
123
- detector name
168
+ Constructor
169
+
170
+ Args:
171
+ controller: Logic for arming and disarming the detector
172
+ writer: Logic for making the detector write persistent data
173
+ config_sigs: Signals to read when describe and read
174
+ configuration are called. Defaults to ().
175
+ name: Device name. Defaults to "".
176
+ writer_timeout: Timeout for frame writing to start, if the
177
+ timeout is reached, ophyd-async assumes the detector
178
+ has a problem and raises an error.
179
+ Defaults to DEFAULT_TIMEOUT.
124
180
  """
125
181
  self._controller = controller
126
182
  self._writer = writer
127
- self._describe: Dict[str, Descriptor] = {}
183
+ self._describe: Dict[str, DataKey] = {}
128
184
  self._config_sigs = list(config_sigs)
129
185
  self._frame_writing_timeout = writer_timeout
186
+ # For prepare
187
+ self._arm_status: Optional[AsyncStatus] = None
188
+ self._trigger_info: Optional[TriggerInfo] = None
189
+ # For kickoff
190
+ self._watchers: List[Callable] = []
191
+ self._fly_status: Optional[WatchableAsyncStatus] = None
192
+ self._fly_start: float
193
+
194
+ self._intial_frame: int
195
+ self._last_frame: int
130
196
  super().__init__(name)
131
197
 
132
198
  @property
@@ -137,14 +203,20 @@ class StandardDetector(
137
203
  def writer(self) -> DetectorWriter:
138
204
  return self._writer
139
205
 
140
- async def check_config_sigs(self):
206
+ @AsyncStatus.wrap
207
+ async def stage(self) -> None:
208
+ # Disarm the detector, stop filewriting, and open file for writing.
209
+ await self._check_config_sigs()
210
+ await asyncio.gather(self.writer.close(), self.controller.disarm())
211
+ self._describe = await self.writer.open()
212
+
213
+ async def _check_config_sigs(self):
141
214
  """Checks configuration signals are named and connected."""
142
215
  for signal in self._config_sigs:
143
- if signal._name == "":
216
+ if signal.name == "":
144
217
  raise Exception(
145
218
  "config signal must be named before it is passed to the detector"
146
219
  )
147
-
148
220
  try:
149
221
  await signal.get_value()
150
222
  except NotImplementedError:
@@ -154,46 +226,116 @@ class StandardDetector(
154
226
  )
155
227
 
156
228
  @AsyncStatus.wrap
157
- async def stage(self) -> None:
158
- """Disarm the detector, stop filewriting, and open file for writing."""
159
- await self.check_config_sigs()
160
- await asyncio.gather(self.writer.close(), self.controller.disarm())
161
- self._describe = await self.writer.open()
162
-
163
- async def describe_configuration(self) -> Dict[str, Descriptor]:
164
- return await merge_gathered_dicts(sig.describe() for sig in self._config_sigs)
229
+ async def unstage(self) -> None:
230
+ # Stop data writing.
231
+ await self.writer.close()
165
232
 
166
233
  async def read_configuration(self) -> Dict[str, Reading]:
167
234
  return await merge_gathered_dicts(sig.read() for sig in self._config_sigs)
168
235
 
169
- def describe(self) -> Dict[str, Descriptor]:
236
+ async def describe_configuration(self) -> Dict[str, DataKey]:
237
+ return await merge_gathered_dicts(sig.describe() for sig in self._config_sigs)
238
+
239
+ async def read(self) -> Dict[str, Reading]:
240
+ # All data is in StreamResources, not Events, so nothing to output here
241
+ return {}
242
+
243
+ async def describe(self) -> Dict[str, DataKey]:
170
244
  return self._describe
171
245
 
172
246
  @AsyncStatus.wrap
173
247
  async def trigger(self) -> None:
174
- """Arm the detector and wait for it to finish."""
248
+ # Arm the detector and wait for it to finish.
175
249
  indices_written = await self.writer.get_indices_written()
176
- written_status = await self.controller.arm(DetectorTrigger.internal, num=1)
177
- await written_status
178
- await self.writer.wait_for_index(
179
- indices_written + 1, timeout=self._frame_writing_timeout
250
+ written_status = await self.controller.arm(
251
+ num=1,
252
+ trigger=DetectorTrigger.internal,
180
253
  )
254
+ await written_status
255
+ end_observation = indices_written + 1
181
256
 
182
- async def read(self) -> Dict[str, Reading]:
183
- """Read the detector"""
184
- # All data is in StreamResources, not Events, so nothing to output here
185
- return {}
257
+ async for index in self.writer.observe_indices_written(
258
+ self._frame_writing_timeout
259
+ ):
260
+ if index >= end_observation:
261
+ break
186
262
 
187
- async def collect_asset_docs(self) -> AsyncIterator[Asset]:
188
- """Collect stream datum documents for all indices written."""
189
- indices_written = await self.writer.get_indices_written()
263
+ def prepare(
264
+ self,
265
+ value: T,
266
+ ) -> AsyncStatus:
267
+ # Just arm detector for the time being
268
+ return AsyncStatus(self._prepare(value))
190
269
 
191
- async for doc in self.writer.collect_stream_docs(indices_written):
192
- yield doc
193
- # async for doc in self.writer.collect_stream_docs(indices_written):
194
- # yield doc
270
+ async def _prepare(self, value: T) -> None:
271
+ """
272
+ Arm detector.
273
+
274
+ Prepare the detector with trigger information. This is determined at and passed
275
+ in from the plan level.
276
+
277
+ This currently only prepares detectors for flyscans and stepscans just use the
278
+ trigger information determined in trigger.
279
+
280
+ To do: Unify prepare to be use for both fly and step scans.
281
+
282
+ Args:
283
+ value: TriggerInfo describing how to trigger the detector
284
+ """
285
+ assert type(value) is TriggerInfo
286
+ self._trigger_info = value
287
+ self._initial_frame = await self.writer.get_indices_written()
288
+ self._last_frame = self._initial_frame + self._trigger_info.num
289
+
290
+ required = self.controller.get_deadtime(self._trigger_info.livetime)
291
+ assert required <= self._trigger_info.deadtime, (
292
+ f"Detector {self.controller} needs at least {required}s deadtime, "
293
+ f"but trigger logic provides only {self._trigger_info.deadtime}s"
294
+ )
295
+ self._arm_status = await self.controller.arm(
296
+ num=self._trigger_info.num,
297
+ trigger=self._trigger_info.trigger,
298
+ exposure=self._trigger_info.livetime,
299
+ )
300
+ self._fly_start = time.monotonic()
195
301
 
196
302
  @AsyncStatus.wrap
197
- async def unstage(self) -> None:
198
- """Stop data writing."""
199
- await self.writer.close()
303
+ async def kickoff(self):
304
+ if not self._arm_status:
305
+ raise Exception("Detector not armed!")
306
+
307
+ @WatchableAsyncStatus.wrap
308
+ async def complete(self):
309
+ assert self._arm_status, "Prepare not run"
310
+ assert self._trigger_info
311
+ async for index in self.writer.observe_indices_written(
312
+ self._frame_writing_timeout
313
+ ):
314
+ yield WatcherUpdate(
315
+ name=self.name,
316
+ current=index,
317
+ initial=self._initial_frame,
318
+ target=self._trigger_info.num,
319
+ unit="",
320
+ precision=0,
321
+ time_elapsed=time.monotonic() - self._fly_start,
322
+ )
323
+ if index >= self._trigger_info.num:
324
+ break
325
+
326
+ async def describe_collect(self) -> Dict[str, DataKey]:
327
+ return self._describe
328
+
329
+ async def collect_asset_docs(
330
+ self, index: Optional[int] = None
331
+ ) -> AsyncIterator[StreamAsset]:
332
+ # Collect stream datum documents for all indices written.
333
+ # The index is optional, and provided for fly scans, however this needs to be
334
+ # retrieved for step scans.
335
+ if index is None:
336
+ index = await self.writer.get_indices_written()
337
+ async for doc in self.writer.collect_stream_docs(index):
338
+ yield doc
339
+
340
+ async def get_index(self) -> int:
341
+ return await self.writer.get_indices_written()
@@ -1,16 +1,25 @@
1
1
  """Base device"""
2
- from __future__ import annotations
3
2
 
4
3
  import asyncio
5
- import logging
6
4
  import sys
7
- from contextlib import suppress
8
- from typing import Any, Dict, Generator, Iterator, Optional, Set, Tuple, TypeVar
5
+ from functools import cached_property
6
+ from logging import LoggerAdapter, getLogger
7
+ from typing import (
8
+ Any,
9
+ Coroutine,
10
+ Dict,
11
+ Generator,
12
+ Iterator,
13
+ Optional,
14
+ Set,
15
+ Tuple,
16
+ TypeVar,
17
+ )
9
18
 
10
19
  from bluesky.protocols import HasName
11
20
  from bluesky.run_engine import call_in_bluesky_event_loop
12
21
 
13
- from .utils import NotConnected, wait_for_connection
22
+ from .utils import DEFAULT_TIMEOUT, NotConnected, wait_for_connection
14
23
 
15
24
 
16
25
  class Device(HasName):
@@ -21,7 +30,10 @@ class Device(HasName):
21
30
 
22
31
  _name: str = ""
23
32
  #: The parent Device if it exists
24
- parent: Optional[Device] = None
33
+ parent: Optional["Device"] = None
34
+ # None if connect hasn't started, a Task if it has
35
+ _connect_task: Optional[asyncio.Task] = None
36
+ _connect_mock_arg: bool = False
25
37
 
26
38
  def __init__(self, name: str = "") -> None:
27
39
  self.set_name(name)
@@ -31,7 +43,13 @@ class Device(HasName):
31
43
  """Return the name of the Device"""
32
44
  return self._name
33
45
 
34
- def children(self) -> Iterator[Tuple[str, Device]]:
46
+ @cached_property
47
+ def log(self):
48
+ return LoggerAdapter(
49
+ getLogger("ophyd_async.devices"), {"ophyd_async_device_name": self.name}
50
+ )
51
+
52
+ def children(self) -> Iterator[Tuple[str, "Device"]]:
35
53
  for attr_name, attr in self.__dict__.items():
36
54
  if attr_name != "parent" and isinstance(attr, Device):
37
55
  yield attr_name, attr
@@ -44,31 +62,68 @@ class Device(HasName):
44
62
  name:
45
63
  New name to set
46
64
  """
65
+
66
+ # Ensure self.log is recreated after a name change
67
+ if hasattr(self, "log"):
68
+ del self.log
69
+
47
70
  self._name = name
48
71
  for attr_name, child in self.children():
49
72
  child_name = f"{name}-{attr_name.rstrip('_')}" if name else ""
50
73
  child.set_name(child_name)
51
74
  child.parent = self
52
75
 
53
- async def connect(self, sim: bool = False):
76
+ async def connect(
77
+ self,
78
+ mock: bool = False,
79
+ timeout: float = DEFAULT_TIMEOUT,
80
+ force_reconnect: bool = False,
81
+ ):
54
82
  """Connect self and all child Devices.
55
83
 
84
+ Contains a timeout that gets propagated to child.connect methods.
85
+
56
86
  Parameters
57
87
  ----------
58
- sim:
59
- If True then connect in simulation mode.
88
+ mock:
89
+ If True then use ``MockSignalBackend`` for all Signals
90
+ timeout:
91
+ Time to wait before failing with a TimeoutError.
60
92
  """
61
- coros = {
62
- name: child_device.connect(sim) for name, child_device in self.children()
63
- }
64
- if coros:
65
- await wait_for_connection(**coros)
93
+ # If previous connect with same args has started and not errored, can use it
94
+ can_use_previous_connect = (
95
+ self._connect_task
96
+ and not (self._connect_task.done() and self._connect_task.exception())
97
+ and self._connect_mock_arg == mock
98
+ )
99
+ if force_reconnect or not can_use_previous_connect:
100
+ # Kick off a connection
101
+ coros = {
102
+ name: child_device.connect(
103
+ mock, timeout=timeout, force_reconnect=force_reconnect
104
+ )
105
+ for name, child_device in self.children()
106
+ }
107
+ self._connect_task = asyncio.create_task(wait_for_connection(**coros))
108
+ self._connect_mock_arg = mock
109
+
110
+ assert self._connect_task, "Connect task not created, this shouldn't happen"
111
+ # Wait for it to complete
112
+ await self._connect_task
66
113
 
67
114
 
68
115
  VT = TypeVar("VT", bound=Device)
69
116
 
70
117
 
71
118
  class DeviceVector(Dict[int, VT], Device):
119
+ """
120
+ Defines device components with indices.
121
+
122
+ In the below example, foos becomes a dictionary on the parent device
123
+ at runtime, so parent.foos[2] returns a FooDevice. For example usage see
124
+ :class:`~ophyd_async.epics.demo.DynamicSensorGroup`
125
+ """
126
+
72
127
  def children(self) -> Generator[Tuple[str, Device], None, None]:
73
128
  for attr_name, attr in self.items():
74
129
  if isinstance(attr, Device):
@@ -84,9 +139,9 @@ class DeviceCollector:
84
139
  If True, call ``device.set_name(variable_name)`` on all collected
85
140
  Devices
86
141
  connect:
87
- If True, call ``device.connect(sim)`` in parallel on all
142
+ If True, call ``device.connect(mock)`` in parallel on all
88
143
  collected Devices
89
- sim:
144
+ mock:
90
145
  If True, connect Signals in simulation mode
91
146
  timeout:
92
147
  How long to wait for connect before logging an exception
@@ -108,12 +163,12 @@ class DeviceCollector:
108
163
  self,
109
164
  set_name=True,
110
165
  connect=True,
111
- sim=False,
166
+ mock=False,
112
167
  timeout: float = 10.0,
113
168
  ):
114
169
  self._set_name = set_name
115
170
  self._connect = connect
116
- self._sim = sim
171
+ self._mock = mock
117
172
  self._timeout = timeout
118
173
  self._names_on_enter: Set[str] = set()
119
174
  self._objects_on_exit: Dict[str, Any] = {}
@@ -140,41 +195,19 @@ class DeviceCollector:
140
195
 
141
196
  async def _on_exit(self) -> None:
142
197
  # Name and kick off connect for devices
143
- tasks: Dict[asyncio.Task, str] = {}
198
+ connect_coroutines: Dict[str, Coroutine] = {}
144
199
  for name, obj in self._objects_on_exit.items():
145
200
  if name not in self._names_on_enter and isinstance(obj, Device):
146
201
  if self._set_name and not obj.name:
147
202
  obj.set_name(name)
148
203
  if self._connect:
149
- task = asyncio.create_task(obj.connect(self._sim))
150
- tasks[task] = name
151
- # Wait for all the signals to have finished
152
- if tasks:
153
- await self._wait_for_tasks(tasks)
154
-
155
- async def _wait_for_tasks(self, tasks: Dict[asyncio.Task, str]):
156
- done, pending = await asyncio.wait(tasks, timeout=self._timeout)
157
- if pending:
158
- msg = f"{len(pending)} Devices did not connect:"
159
- for t in pending:
160
- t.cancel()
161
- with suppress(Exception):
162
- await t
163
- e = t.exception()
164
- msg += f"\n {tasks[t]}: {type(e).__name__}"
165
- lines = str(e).splitlines()
166
- if len(lines) <= 1:
167
- msg += f": {e}"
168
- else:
169
- msg += "".join(f"\n {line}" for line in lines)
170
- logging.error(msg)
171
- raised = [t for t in done if t.exception()]
172
- if raised:
173
- logging.error(f"{len(raised)} Devices raised an error:")
174
- for t in raised:
175
- logging.exception(f" {tasks[t]}:", exc_info=t.exception())
176
- if pending or raised:
177
- raise NotConnected("Not all Devices connected")
204
+ connect_coroutines[name] = obj.connect(
205
+ self._mock, timeout=self._timeout
206
+ )
207
+
208
+ # Connect to all the devices
209
+ if connect_coroutines:
210
+ await wait_for_connection(**connect_coroutines)
178
211
 
179
212
  async def __aexit__(self, type, value, traceback):
180
213
  self._objects_on_exit = self._caller_locals()
@@ -182,4 +215,12 @@ class DeviceCollector:
182
215
 
183
216
  def __exit__(self, type_, value, traceback):
184
217
  self._objects_on_exit = self._caller_locals()
185
- return call_in_bluesky_event_loop(self._on_exit())
218
+ try:
219
+ fut = call_in_bluesky_event_loop(self._on_exit())
220
+ except RuntimeError:
221
+ raise NotConnected(
222
+ "Could not connect devices. Is the bluesky event loop running? See "
223
+ "https://blueskyproject.io/ophyd-async/main/"
224
+ "user/explanations/event-loop-choice.html for more info."
225
+ )
226
+ return fut