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
@@ -2,7 +2,8 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
  import functools
5
- from collections.abc import AsyncGenerator, Awaitable, Callable, Mapping
5
+ import time
6
+ from collections.abc import AsyncGenerator, Awaitable, Callable
6
7
  from typing import Any, Generic, cast
7
8
 
8
9
  from bluesky.protocols import (
@@ -17,7 +18,6 @@ from event_model import DataKey
17
18
  from ._device import Device, DeviceConnector
18
19
  from ._mock_signal_backend import MockSignalBackend
19
20
  from ._protocol import (
20
- AsyncConfigurable,
21
21
  AsyncReadable,
22
22
  AsyncStageable,
23
23
  Reading,
@@ -28,7 +28,7 @@ from ._signal_backend import (
28
28
  SignalDatatypeV,
29
29
  )
30
30
  from ._soft_signal_backend import SoftSignalBackend
31
- from ._status import AsyncStatus
31
+ from ._status import AsyncStatus, completed_status
32
32
  from ._utils import (
33
33
  CALCULATE_TIMEOUT,
34
34
  DEFAULT_TIMEOUT,
@@ -97,32 +97,37 @@ class Signal(Device, Generic[SignalDatatypeT]):
97
97
 
98
98
 
99
99
  class _SignalCache(Generic[SignalDatatypeT]):
100
- def __init__(self, backend: SignalBackend[SignalDatatypeT], signal: Signal):
101
- self._signal = signal
100
+ def __init__(self, backend: SignalBackend[SignalDatatypeT], signal: Signal) -> None:
101
+ self._signal: Signal[Any] = signal
102
102
  self._staged = False
103
103
  self._listeners: dict[Callback, bool] = {}
104
104
  self._valid = asyncio.Event()
105
105
  self._reading: Reading[SignalDatatypeT] | None = None
106
- self.backend = backend
106
+ self.backend: SignalBackend[SignalDatatypeT] = backend
107
107
  signal.log.debug(f"Making subscription on source {signal.source}")
108
108
  backend.set_callback(self._callback)
109
109
 
110
- def close(self):
110
+ def close(self) -> None:
111
111
  self.backend.set_callback(None)
112
112
  self._signal.log.debug(f"Closing subscription on source {self._signal.source}")
113
113
 
114
+ def _ensure_reading(self) -> Reading[SignalDatatypeT]:
115
+ if not self._reading:
116
+ msg = "Monitor not working"
117
+ raise RuntimeError(msg)
118
+ return self._reading
119
+
114
120
  async def get_reading(self) -> Reading[SignalDatatypeT]:
115
121
  await self._valid.wait()
116
- assert self._reading is not None, "Monitor not working"
117
- return self._reading
122
+ return self._ensure_reading()
118
123
 
119
124
  async def get_value(self) -> SignalDatatypeT:
120
- reading = await self.get_reading()
125
+ reading: Reading[SignalDatatypeT] = await self.get_reading()
121
126
  return reading["value"]
122
127
 
123
- def _callback(self, reading: Reading[SignalDatatypeT]):
128
+ def _callback(self, reading: Reading[SignalDatatypeT]) -> None:
124
129
  self._signal.log.debug(
125
- f"Updated subscription: reading of source {self._signal.source} changed"
130
+ f"Updated subscription: reading of source {self._signal.source} changed "
126
131
  f"from {self._reading} to {reading}"
127
132
  )
128
133
  self._reading = reading
@@ -134,12 +139,10 @@ class _SignalCache(Generic[SignalDatatypeT]):
134
139
  self,
135
140
  function: Callback[dict[str, Reading[SignalDatatypeT]] | SignalDatatypeT],
136
141
  want_value: bool,
137
- ):
138
- assert self._reading, "Monitor not working"
139
- if want_value:
140
- function(self._reading["value"])
141
- else:
142
- function({self._signal.name: self._reading})
142
+ ) -> None:
143
+ function(self._ensure_reading()["value"]) if want_value else function(
144
+ {self._signal.name: self._ensure_reading()}
145
+ )
143
146
 
144
147
  def subscribe(self, function: Callback, want_value: bool) -> None:
145
148
  self._listeners[function] = want_value
@@ -150,7 +153,7 @@ class _SignalCache(Generic[SignalDatatypeT]):
150
153
  self._listeners.pop(function)
151
154
  return self._staged or bool(self._listeners)
152
155
 
153
- def set_staged(self, staged: bool):
156
+ def set_staged(self, staged: bool) -> bool:
154
157
  self._staged = staged
155
158
  return self._staged or bool(self._listeners)
156
159
 
@@ -167,7 +170,10 @@ class SignalR(Signal[SignalDatatypeT], AsyncReadable, AsyncStageable, Subscribab
167
170
  if cached is None:
168
171
  cached = self._cache is not None
169
172
  if cached:
170
- assert self._cache, f"{self.source} not being monitored"
173
+ if not self._cache:
174
+ msg = f"{self.source} not being monitored"
175
+ raise RuntimeError(msg)
176
+ # assert self._cache, f"{self.source} not being monitored"
171
177
  return self._cache
172
178
  else:
173
179
  return self._connector.backend
@@ -301,137 +307,70 @@ def soft_signal_r_and_setter(
301
307
  return (signal, backend.set_value)
302
308
 
303
309
 
304
- def _generate_assert_error_msg(name: str, expected_result, actual_result) -> str:
305
- WARNING = "\033[93m"
306
- FAIL = "\033[91m"
307
- ENDC = "\033[0m"
308
- return (
309
- f"Expected {WARNING}{name}{ENDC} to produce"
310
- + f"\n{FAIL}{expected_result}{ENDC}"
311
- + f"\nbut actually got \n{FAIL}{actual_result}{ENDC}"
312
- )
313
-
314
-
315
- async def assert_value(signal: SignalR[SignalDatatypeT], value: Any) -> None:
316
- """Assert a signal's value and compare it an expected signal.
310
+ async def observe_value(
311
+ signal: SignalR[SignalDatatypeT],
312
+ timeout: float | None = None,
313
+ done_status: Status | None = None,
314
+ done_timeout: float | None = None,
315
+ ) -> AsyncGenerator[SignalDatatypeT, None]:
316
+ """Subscribe to the value of a signal so it can be iterated from.
317
317
 
318
318
  Parameters
319
319
  ----------
320
320
  signal:
321
- signal with get_value.
322
- value:
323
- The expected value from the signal.
321
+ Call subscribe_value on this at the start, and clear_sub on it at the
322
+ end
323
+ timeout:
324
+ If given, how long to wait for each updated value in seconds. If an update
325
+ is not produced in this time then raise asyncio.TimeoutError
326
+ done_status:
327
+ If this status is complete, stop observing and make the iterator return.
328
+ If it raises an exception then this exception will be raised by the iterator.
329
+ done_timeout:
330
+ If given, the maximum time to watch a signal, in seconds. If the loop is still
331
+ being watched after this length, raise asyncio.TimeoutError. This should be used
332
+ instead of on an 'asyncio.wait_for' timeout
324
333
 
325
334
  Notes
326
335
  -----
327
- Example usage::
328
- await assert_value(signal, value)
336
+ Due to a rare condition with busy signals, it is not recommended to use this
337
+ function with asyncio.timeout, including in an 'asyncio.wait_for' loop. Instead,
338
+ this timeout should be given to the done_timeout parameter.
329
339
 
330
- """
331
- actual_value = await signal.get_value()
332
- assert actual_value == value, _generate_assert_error_msg(
333
- name=signal.name,
334
- expected_result=value,
335
- actual_result=actual_value,
336
- )
337
-
338
-
339
- async def assert_reading(
340
- readable: AsyncReadable, expected_reading: Mapping[str, Reading]
341
- ) -> None:
342
- """Assert readings from readable.
343
-
344
- Parameters
345
- ----------
346
- readable:
347
- Callable with readable.read function that generate readings.
348
-
349
- reading:
350
- The expected readings from the readable.
351
-
352
- Notes
353
- -----
354
340
  Example usage::
355
- await assert_reading(readable, reading)
356
-
357
- """
358
- actual_reading = await readable.read()
359
- assert expected_reading == actual_reading, _generate_assert_error_msg(
360
- name=readable.name,
361
- expected_result=expected_reading,
362
- actual_result=actual_reading,
363
- )
364
-
365
-
366
- async def assert_configuration(
367
- configurable: AsyncConfigurable,
368
- configuration: Mapping[str, Reading],
369
- ) -> None:
370
- """Assert readings from Configurable.
371
-
372
- Parameters
373
- ----------
374
- configurable:
375
- Configurable with Configurable.read function that generate readings.
376
-
377
- configuration:
378
- The expected readings from configurable.
379
-
380
- Notes
381
- -----
382
- Example usage::
383
- await assert_configuration(configurable configuration)
384
341
 
342
+ async for value in observe_value(sig):
343
+ do_something_with(value)
385
344
  """
386
- actual_configurable = await configurable.read_configuration()
387
- assert configuration == actual_configurable, _generate_assert_error_msg(
388
- name=configurable.name,
389
- expected_result=configuration,
390
- actual_result=actual_configurable,
391
- )
392
-
393
345
 
394
- def assert_emitted(docs: Mapping[str, list[dict]], **numbers: int):
395
- """Assert emitted document generated by running a Bluesky plan
396
-
397
- Parameters
398
- ----------
399
- Doc:
400
- A dictionary
346
+ async for _, value in observe_signals_value(
347
+ signal,
348
+ timeout=timeout,
349
+ done_status=done_status,
350
+ done_timeout=done_timeout,
351
+ ):
352
+ yield value
401
353
 
402
- numbers:
403
- expected emission in kwarg from
404
354
 
405
- Notes
406
- -----
407
- Example usage::
408
- assert_emitted(docs, start=1, descriptor=1,
409
- resource=1, datum=1, event=1, stop=1)
410
- """
411
- assert list(docs) == list(numbers), _generate_assert_error_msg(
412
- name="documents",
413
- expected_result=list(numbers),
414
- actual_result=list(docs),
415
- )
416
- actual_numbers = {name: len(d) for name, d in docs.items()}
417
- assert actual_numbers == numbers, _generate_assert_error_msg(
418
- name="emitted",
419
- expected_result=numbers,
420
- actual_result=actual_numbers,
421
- )
355
+ def _get_iteration_timeout(
356
+ timeout: float | None, overall_deadline: float | None
357
+ ) -> float | None:
358
+ overall_deadline = overall_deadline - time.monotonic() if overall_deadline else None
359
+ return min([x for x in [overall_deadline, timeout] if x is not None], default=None)
422
360
 
423
361
 
424
- async def observe_value(
425
- signal: SignalR[SignalDatatypeT],
362
+ async def observe_signals_value(
363
+ *signals: SignalR[SignalDatatypeT],
426
364
  timeout: float | None = None,
427
365
  done_status: Status | None = None,
428
- ) -> AsyncGenerator[SignalDatatypeT, None]:
366
+ done_timeout: float | None = None,
367
+ ) -> AsyncGenerator[tuple[SignalR[SignalDatatypeT], SignalDatatypeT], None]:
429
368
  """Subscribe to the value of a signal so it can be iterated from.
430
369
 
431
370
  Parameters
432
371
  ----------
433
- signal:
434
- Call subscribe_value on this at the start, and clear_sub on it at the
372
+ signals:
373
+ Call subscribe_value on all the signals at the start, and clear_sub on it at the
435
374
  end
436
375
  timeout:
437
376
  If given, how long to wait for each updated value in seconds. If an update
@@ -439,36 +378,57 @@ async def observe_value(
439
378
  done_status:
440
379
  If this status is complete, stop observing and make the iterator return.
441
380
  If it raises an exception then this exception will be raised by the iterator.
381
+ done_timeout:
382
+ If given, the maximum time to watch a signal, in seconds. If the loop is still
383
+ being watched after this length, raise asyncio.TimeoutError. This should be used
384
+ instead of on an 'asyncio.wait_for' timeout
442
385
 
443
386
  Notes
444
387
  -----
445
388
  Example usage::
446
389
 
447
- async for value in observe_value(sig):
448
- do_something_with(value)
390
+ async for signal,value in observe_signals_values(sig1,sig2,..):
391
+ if signal is sig1:
392
+ do_something_with(value)
393
+ elif signal is sig2:
394
+ do_something_else_with(value)
449
395
  """
396
+ q: asyncio.Queue[tuple[SignalR[SignalDatatypeT], SignalDatatypeT] | Status] = (
397
+ asyncio.Queue()
398
+ )
399
+
400
+ cbs: dict[SignalR, Callback] = {}
401
+ for signal in signals:
450
402
 
451
- q: asyncio.Queue[SignalDatatypeT | Status] = asyncio.Queue()
403
+ def queue_value(value: SignalDatatypeT, signal=signal):
404
+ q.put_nowait((signal, value))
405
+
406
+ cbs[signal] = queue_value
407
+ signal.subscribe_value(queue_value)
452
408
 
453
409
  if done_status is not None:
454
410
  done_status.add_callback(q.put_nowait)
455
-
456
- signal.subscribe_value(q.put_nowait)
411
+ overall_deadline = time.monotonic() + done_timeout if done_timeout else None
457
412
  try:
458
413
  while True:
459
- # yield here in case something else is filling the queue
460
- # like in test_observe_value_times_out_with_no_external_task()
461
- await asyncio.sleep(0)
462
- item = await asyncio.wait_for(q.get(), timeout)
414
+ if overall_deadline and time.monotonic() >= overall_deadline:
415
+ raise asyncio.TimeoutError(
416
+ f"observe_value was still observing signals "
417
+ f"{[signal.source for signal in signals]} after "
418
+ f"timeout {done_timeout}s"
419
+ )
420
+ iteration_timeout = _get_iteration_timeout(timeout, overall_deadline)
421
+ item = await asyncio.wait_for(q.get(), iteration_timeout)
463
422
  if done_status and item is done_status:
464
423
  if exc := done_status.exception():
465
424
  raise exc
466
425
  else:
467
426
  break
468
427
  else:
469
- yield cast(SignalDatatypeT, item)
428
+ yield cast(tuple[SignalR[SignalDatatypeT], SignalDatatypeT], item)
470
429
  finally:
471
- signal.clear_sub(q.put_nowait)
430
+ for signal, cb in cbs.items():
431
+ signal.clear_sub(cb)
472
432
 
473
433
 
474
434
  class _ValueChecker(Generic[SignalDatatypeT]):
@@ -533,15 +493,16 @@ async def wait_for_value(
533
493
  async def set_and_wait_for_other_value(
534
494
  set_signal: SignalW[SignalDatatypeT],
535
495
  set_value: SignalDatatypeT,
536
- read_signal: SignalR[SignalDatatypeV],
537
- read_value: SignalDatatypeV,
496
+ match_signal: SignalR[SignalDatatypeV],
497
+ match_value: SignalDatatypeV | Callable[[SignalDatatypeV], bool],
538
498
  timeout: float = DEFAULT_TIMEOUT,
539
499
  set_timeout: float | None = None,
500
+ wait_for_set_completion: bool = True,
540
501
  ) -> AsyncStatus:
541
502
  """Set a signal and monitor another signal until it has the specified value.
542
503
 
543
504
  This function sets a set_signal to a specified set_value and waits for
544
- a read_signal to have the read_value.
505
+ a match_signal to have the match_value.
545
506
 
546
507
  Parameters
547
508
  ----------
@@ -549,14 +510,16 @@ async def set_and_wait_for_other_value(
549
510
  The signal to set
550
511
  set_value:
551
512
  The value to set it to
552
- read_signal:
513
+ match_signal:
553
514
  The signal to monitor
554
- read_value:
515
+ match_value:
555
516
  The value to wait for
556
517
  timeout:
557
518
  How long to wait for the signal to have the value
558
519
  set_timeout:
559
520
  How long to wait for the set to complete
521
+ wait_for_set_completion:
522
+ This will wait for set completion #More info in how-to docs
560
523
 
561
524
  Notes
562
525
  -----
@@ -565,7 +528,7 @@ async def set_and_wait_for_other_value(
565
528
  set_and_wait_for_value(device.acquire, 1, device.acquire_rbv, 1)
566
529
  """
567
530
  # Start monitoring before the set to avoid a race condition
568
- values_gen = observe_value(read_signal)
531
+ values_gen = observe_value(match_signal)
569
532
 
570
533
  # Get the initial value from the monitor to make sure we've created it
571
534
  current_value = await anext(values_gen)
@@ -573,28 +536,33 @@ async def set_and_wait_for_other_value(
573
536
  status = set_signal.set(set_value, timeout=set_timeout)
574
537
 
575
538
  # If the value was the same as before no need to wait for it to change
576
- if current_value != read_value:
539
+ if current_value != match_value:
577
540
 
578
541
  async def _wait_for_value():
579
542
  async for value in values_gen:
580
- if value == read_value:
543
+ if value == match_value:
581
544
  break
582
545
 
583
546
  try:
584
547
  await asyncio.wait_for(_wait_for_value(), timeout)
548
+ if wait_for_set_completion:
549
+ await status
550
+ return status
585
551
  except asyncio.TimeoutError as e:
586
552
  raise TimeoutError(
587
- f"{read_signal.name} didn't match {read_value} in {timeout}s"
553
+ f"{match_signal.name} didn't match {match_value} in {timeout}s"
588
554
  ) from e
589
555
 
590
- return status
556
+ return completed_status()
591
557
 
592
558
 
593
559
  async def set_and_wait_for_value(
594
560
  signal: SignalRW[SignalDatatypeT],
595
561
  value: SignalDatatypeT,
562
+ match_value: SignalDatatypeT | Callable[[SignalDatatypeT], bool] | None = None,
596
563
  timeout: float = DEFAULT_TIMEOUT,
597
564
  status_timeout: float | None = None,
565
+ wait_for_set_completion: bool = True,
598
566
  ) -> AsyncStatus:
599
567
  """Set a signal and monitor it until it has that value.
600
568
 
@@ -609,10 +577,15 @@ async def set_and_wait_for_value(
609
577
  The signal to set
610
578
  value:
611
579
  The value to set it to
580
+ match_value:
581
+ The expected value of the signal after the operation.
582
+ Used to verify that the set operation was successful.
612
583
  timeout:
613
584
  How long to wait for the signal to have the value
614
585
  status_timeout:
615
586
  How long the returned Status will wait for the set to complete
587
+ wait_for_set_completion:
588
+ This will wait for set completion #More info in how-to docs
616
589
 
617
590
  Notes
618
591
  -----
@@ -620,6 +593,46 @@ async def set_and_wait_for_value(
620
593
 
621
594
  set_and_wait_for_value(device.acquire, 1)
622
595
  """
596
+ if match_value is None:
597
+ match_value = value
623
598
  return await set_and_wait_for_other_value(
624
- signal, value, signal, value, timeout, status_timeout
599
+ signal,
600
+ value,
601
+ signal,
602
+ match_value,
603
+ timeout,
604
+ status_timeout,
605
+ wait_for_set_completion,
625
606
  )
607
+
608
+
609
+ def walk_rw_signals(device: Device, path_prefix: str = "") -> dict[str, SignalRW[Any]]:
610
+ """Retrieve all SignalRWs from a device.
611
+
612
+ Stores retrieved signals with their dotted attribute paths in a dictionary. Used as
613
+ part of saving and loading a device.
614
+
615
+ Parameters
616
+ ----------
617
+ device : Device
618
+ Ophyd device to retrieve read-write signals from.
619
+
620
+ path_prefix : str
621
+ For internal use, leave blank when calling the method.
622
+
623
+ Returns
624
+ -------
625
+ SignalRWs : dict
626
+ A dictionary matching the string attribute path of a SignalRW with the
627
+ signal itself.
628
+
629
+ """
630
+ signals: dict[str, SignalRW[Any]] = {}
631
+
632
+ for attr_name, attr in device.children():
633
+ dot_path = f"{path_prefix}{attr_name}"
634
+ if type(attr) is SignalRW:
635
+ signals[dot_path] = attr
636
+ attr_signals = walk_rw_signals(attr, path_prefix=dot_path + ".")
637
+ signals.update(attr_signals)
638
+ return signals
@@ -10,7 +10,10 @@ from ._table import Table
10
10
  from ._utils import Callback, StrictEnum, T
11
11
 
12
12
  DTypeScalar_co = TypeVar("DTypeScalar_co", covariant=True, bound=np.generic)
13
- Array1D = np.ndarray[tuple[int], np.dtype[DTypeScalar_co]]
13
+ # To be a 1D array shape should really be tuple[int], but np.array()
14
+ # currently produces tuple[int, ...] even when it has 1D input args
15
+ # https://github.com/numpy/numpy/issues/28077#issuecomment-2566485178
16
+ Array1D = np.ndarray[tuple[int, ...], np.dtype[DTypeScalar_co]]
14
17
  Primitive = bool | int | float | str
15
18
  # NOTE: if you change this union then update the docs to match
16
19
  SignalDatatype = (
@@ -175,7 +175,8 @@ class SoftSignalBackend(SignalBackend[SignalDatatypeT]):
175
175
  return self.reading["value"]
176
176
 
177
177
  def set_callback(self, callback: Callback[Reading[SignalDatatypeT]] | None) -> None:
178
+ if callback and self.callback:
179
+ raise RuntimeError("Cannot set a callback when one is already set")
178
180
  if callback:
179
- assert not self.callback, "Cannot set a callback when one is already set"
180
181
  callback(self.reading)
181
182
  self.callback = callback
@@ -1,6 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
- from collections.abc import Sequence
3
+ from collections.abc import Callable, Sequence
4
4
  from typing import Annotated, Any, TypeVar, get_origin
5
5
 
6
6
  import numpy as np
@@ -19,6 +19,13 @@ def _concat(value1, value2):
19
19
  return value1 + value2
20
20
 
21
21
 
22
+ def _make_default_factory(dtype: np.dtype) -> Callable[[], np.ndarray]:
23
+ def numpy_array_default_factory() -> np.ndarray:
24
+ return np.array([], dtype)
25
+
26
+ return numpy_array_default_factory
27
+
28
+
22
29
  class Table(BaseModel):
23
30
  """An abstraction of a Table of str to numpy array."""
24
31
 
@@ -32,6 +39,11 @@ class Table(BaseModel):
32
39
  # so it is strictly checked against the BaseModel we are supplied.
33
40
  model_config = ConfigDict(extra="allow")
34
41
 
42
+ # Add an init method to match the above model config, otherwise the type
43
+ # checker will not think we can pass arbitrary kwargs into the base class init
44
+ def __init__(self, **kwargs):
45
+ super().__init__(**kwargs)
46
+
35
47
  @classmethod
36
48
  def __init_subclass__(cls):
37
49
  # But forbit extra in subclasses so it gets validated
@@ -45,9 +57,7 @@ class Table(BaseModel):
45
57
  NpArrayPydanticAnnotation.factory(
46
58
  data_type=dtype.type, dimensions=1, strict_data_typing=False
47
59
  ),
48
- Field(
49
- default_factory=lambda dtype=dtype: np.array([], dtype=dtype)
50
- ),
60
+ Field(default_factory=_make_default_factory(dtype)),
51
61
  ]
52
62
  elif get_origin(anno) is Sequence:
53
63
  new_anno = Annotated[anno, Field(default_factory=list)]
@@ -73,9 +83,6 @@ class Table(BaseModel):
73
83
  }
74
84
  )
75
85
 
76
- def __eq__(self, value: object) -> bool:
77
- return super().__eq__(value)
78
-
79
86
  def numpy_dtype(self) -> np.dtype:
80
87
  dtype = []
81
88
  for k, v in self:
@@ -94,8 +101,10 @@ class Table(BaseModel):
94
101
  v = v[selection]
95
102
  if array is None:
96
103
  array = np.empty(v.shape, dtype=self.numpy_dtype())
97
- array[k] = v
98
- assert array is not None
104
+ array[k] = v # type: ignore
105
+ if array is None:
106
+ msg = "No arrays found in table"
107
+ raise ValueError(msg)
99
108
  return array
100
109
 
101
110
  @model_validator(mode="before")
@@ -118,10 +127,12 @@ class Table(BaseModel):
118
127
  # Convert to correct dtype, but only if we don't lose precision
119
128
  # as a result
120
129
  cast_value = np.array(data_value).astype(expected_dtype)
121
- assert np.array_equal(data_value, cast_value), (
122
- f"{field_name}: Cannot cast {data_value} to {expected_dtype} "
123
- "without losing precision"
124
- )
130
+ if not np.array_equal(data_value, cast_value):
131
+ msg = (
132
+ f"{field_name}: Cannot cast {data_value} to {expected_dtype} "
133
+ "without losing precision"
134
+ )
135
+ raise ValueError(msg)
125
136
  data_dict[field_name] = cast_value
126
137
  return data_dict
127
138
 
@@ -130,7 +141,9 @@ class Table(BaseModel):
130
141
  lengths: dict[int, set[str]] = {}
131
142
  for field_name, field_value in self:
132
143
  lengths.setdefault(len(field_value), set()).add(field_name)
133
- assert len(lengths) <= 1, f"Columns should be same length, got {lengths=}"
144
+ if len(lengths) > 1:
145
+ msg = f"Columns should be same length, got {lengths=}"
146
+ raise ValueError(msg)
134
147
  return self
135
148
 
136
149
  def __len__(self) -> int: