dls-dodal 1.29.3__py3-none-any.whl → 1.30.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 (88) hide show
  1. {dls_dodal-1.29.3.dist-info → dls_dodal-1.30.0.dist-info}/METADATA +28 -43
  2. dls_dodal-1.30.0.dist-info/RECORD +132 -0
  3. {dls_dodal-1.29.3.dist-info → dls_dodal-1.30.0.dist-info}/WHEEL +1 -1
  4. dls_dodal-1.30.0.dist-info/entry_points.txt +3 -0
  5. dodal/__init__.py +1 -4
  6. dodal/_version.py +2 -2
  7. dodal/beamlines/__init__.py +3 -1
  8. dodal/beamlines/i03.py +28 -23
  9. dodal/beamlines/i04.py +34 -12
  10. dodal/beamlines/i13_1.py +66 -0
  11. dodal/beamlines/i22.py +5 -5
  12. dodal/beamlines/i24.py +15 -1
  13. dodal/beamlines/p38.py +7 -7
  14. dodal/beamlines/p45.py +7 -5
  15. dodal/beamlines/p99.py +61 -0
  16. dodal/cli.py +6 -3
  17. dodal/common/beamlines/beamline_parameters.py +2 -2
  18. dodal/common/beamlines/beamline_utils.py +6 -5
  19. dodal/common/maths.py +1 -3
  20. dodal/common/types.py +2 -3
  21. dodal/common/udc_directory_provider.py +14 -3
  22. dodal/common/visit.py +2 -3
  23. dodal/devices/CTAB.py +22 -17
  24. dodal/devices/aperturescatterguard.py +114 -136
  25. dodal/devices/areadetector/adaravis.py +8 -6
  26. dodal/devices/areadetector/adsim.py +2 -3
  27. dodal/devices/areadetector/adutils.py +20 -12
  28. dodal/devices/areadetector/plugins/MJPG.py +0 -4
  29. dodal/devices/attenuator.py +4 -4
  30. dodal/devices/cryostream.py +19 -7
  31. dodal/devices/detector/__init__.py +13 -2
  32. dodal/devices/detector/det_dim_constants.py +2 -2
  33. dodal/devices/detector/det_dist_to_beam_converter.py +1 -1
  34. dodal/devices/detector/detector.py +8 -7
  35. dodal/devices/detector/detector_motion.py +38 -31
  36. dodal/devices/eiger.py +23 -23
  37. dodal/devices/eiger_odin.py +12 -13
  38. dodal/devices/fast_grid_scan.py +4 -3
  39. dodal/devices/fluorescence_detector_motion.py +13 -4
  40. dodal/devices/focusing_mirror.py +66 -66
  41. dodal/devices/hutch_shutter.py +4 -4
  42. dodal/devices/i22/dcm.py +4 -3
  43. dodal/devices/i22/fswitch.py +4 -4
  44. dodal/devices/i22/nxsas.py +23 -32
  45. dodal/devices/i24/dcm.py +42 -0
  46. dodal/devices/i24/pmac.py +47 -8
  47. dodal/devices/ipin.py +7 -4
  48. dodal/devices/linkam3.py +11 -5
  49. dodal/devices/logging_ophyd_device.py +1 -1
  50. dodal/devices/motors.py +31 -5
  51. dodal/devices/oav/grid_overlay.py +1 -0
  52. dodal/devices/oav/microns_for_zoom_levels.json +1 -1
  53. dodal/devices/oav/oav_detector.py +2 -0
  54. dodal/devices/oav/oav_parameters.py +18 -10
  55. dodal/devices/oav/oav_to_redis_forwarder.py +100 -0
  56. dodal/devices/oav/pin_image_recognition/__init__.py +19 -17
  57. dodal/devices/oav/pin_image_recognition/utils.py +5 -6
  58. dodal/devices/oav/utils.py +3 -17
  59. dodal/devices/p99/__init__.py +0 -0
  60. dodal/devices/p99/sample_stage.py +43 -0
  61. dodal/devices/robot.py +30 -18
  62. dodal/devices/scintillator.py +8 -5
  63. dodal/devices/smargon.py +3 -3
  64. dodal/devices/status.py +2 -31
  65. dodal/devices/tetramm.py +4 -4
  66. dodal/devices/thawer.py +5 -3
  67. dodal/devices/undulator_dcm.py +6 -8
  68. dodal/devices/util/adjuster_plans.py +2 -2
  69. dodal/devices/util/epics_util.py +6 -8
  70. dodal/devices/util/lookup_tables.py +2 -3
  71. dodal/devices/util/save_panda.py +87 -0
  72. dodal/devices/util/test_utils.py +17 -0
  73. dodal/devices/webcam.py +3 -8
  74. dodal/devices/xbpm_feedback.py +0 -23
  75. dodal/devices/zebra.py +10 -10
  76. dodal/devices/zebra_controlled_shutter.py +3 -3
  77. dodal/devices/zocalo/zocalo_interaction.py +10 -2
  78. dodal/devices/zocalo/zocalo_results.py +31 -18
  79. dodal/log.py +14 -5
  80. dodal/plans/data_session_metadata.py +1 -0
  81. dodal/plans/motor_util_plans.py +117 -0
  82. dodal/utils.py +74 -26
  83. dls_dodal-1.29.3.dist-info/RECORD +0 -124
  84. dls_dodal-1.29.3.dist-info/entry_points.txt +0 -2
  85. dodal/devices/qbpm1.py +0 -8
  86. {dls_dodal-1.29.3.dist-info → dls_dodal-1.30.0.dist-info}/LICENSE +0 -0
  87. {dls_dodal-1.29.3.dist-info → dls_dodal-1.30.0.dist-info}/top_level.txt +0 -0
  88. /dodal/devices/i24/{I24_detector_motion.py → i24_detector_motion.py} +0 -0
@@ -1,37 +1,44 @@
1
- from enum import IntEnum
1
+ from enum import Enum
2
2
 
3
- from ophyd import Component as Cpt
4
- from ophyd import Device, EpicsMotor, EpicsSignal, EpicsSignalRO
3
+ from ophyd_async.core import Device
4
+ from ophyd_async.epics.motion import Motor
5
+ from ophyd_async.epics.signal import epics_signal_r, epics_signal_rw
5
6
 
6
7
 
7
- class ShutterState(IntEnum):
8
- CLOSED = 0
9
- OPEN = 1
8
+ class ShutterState(str, Enum):
9
+ CLOSED = "Closed"
10
+ OPEN = "Open"
10
11
 
11
12
 
12
13
  class DetectorMotion(Device):
13
- """Physical motion and interlocks for detector travel"""
14
-
15
- upstream_x = Cpt(EpicsMotor, "-MO-DET-01:UPSTREAMX")
16
- downstream_x = Cpt(EpicsMotor, "-MO-DET-01:DOWNSTREAMX")
17
- x = Cpt(EpicsMotor, "-MO-DET-01:X")
18
- y = Cpt(EpicsMotor, "-MO-DET-01:Y")
19
- z = Cpt(EpicsMotor, "-MO-DET-01:Z")
20
- yaw = Cpt(EpicsMotor, "-MO-DET-01:YAW")
21
- shutter = Cpt(EpicsSignal, "-MO-DET-01:SET_SHUTTER_STATE") # 0=closed, 1=open
22
- # monitors
23
- shutter_closed_lim = Cpt(
24
- EpicsSignalRO, "-MO-DET-01:CLOSE_LIMIT"
25
- ) # on limit = 1, off = 0
26
- shutter_open_lim = Cpt(
27
- EpicsSignalRO, "-MO-DET-01:OPEN_LIMIT"
28
- ) # on limit = 1, off = 0
29
- z_disabled = Cpt(
30
- EpicsSignalRO, "-MO-DET-01:Z:DISABLED"
31
- ) # robot interlock, 0=ok to move, 1=blocked
32
- crate_power = Cpt(
33
- EpicsSignalRO, "-MO-PMAC-02:CRATE2_HEALTHY"
34
- ) # returns 0 if no power
35
- in_robot_load_safe_position = Cpt(
36
- EpicsSignalRO, "-MO-PMAC-02:GPIO_INP_BITS.B2"
37
- ) # returns 1 if safe
14
+ def __init__(self, prefix: str, name: str = ""):
15
+ device_prefix = "-MO-DET-01:"
16
+ pmac_prefix = "-MO-PMAC-02:"
17
+
18
+ self.upstream_x = Motor(f"{prefix}{device_prefix}UPSTREAMX")
19
+ self.downstream_x = Motor(f"{prefix}{device_prefix}DOWNSTREAMX")
20
+ self.x = Motor(f"{prefix}{device_prefix}X")
21
+ self.y = Motor(f"{prefix}{device_prefix}Y")
22
+ self.z = Motor(f"{prefix}{device_prefix}Z")
23
+ self.yaw = Motor(f"{prefix}{device_prefix}YAW")
24
+
25
+ self.shutter = epics_signal_rw(
26
+ ShutterState, f"{prefix}{device_prefix}SET_SHUTTER_STATE"
27
+ )
28
+ self.shutter_closed_lim = epics_signal_r(
29
+ float, f"{prefix}{device_prefix}CLOSE_LIMIT"
30
+ ) # on limit = 1, off = 0
31
+ self.shutter_open_lim = epics_signal_r(
32
+ float, f"{prefix}{device_prefix}OPEN_LIMIT"
33
+ ) # on limit = 1, off = 0
34
+ self.z_disabled = epics_signal_r(
35
+ float, f"{prefix}{device_prefix}Z:DISABLED"
36
+ ) # robot interlock, 0=ok to move, 1=blocked
37
+ self.crate_power = epics_signal_r(
38
+ float, f"{prefix}{pmac_prefix}CRATE2_HEALTHY"
39
+ ) # returns 0 if no power
40
+ self.in_robot_load_safe_position = epics_signal_r(
41
+ int, f"{prefix}{pmac_prefix}GPIO_INP_BITS.B2"
42
+ ) # returns 1 if safe
43
+
44
+ super().__init__(name)
dodal/devices/eiger.py CHANGED
@@ -1,8 +1,9 @@
1
+ # type: ignore # Eiger will soon be ophyd-async https://github.com/DiamondLightSource/dodal/issues/700
1
2
  from enum import Enum
2
3
 
3
4
  from ophyd import Component, Device, EpicsSignalRO, Signal
4
5
  from ophyd.areadetector.cam import EigerDetectorCam
5
- from ophyd.status import AndStatus, Status, SubscriptionStatus
6
+ from ophyd.status import AndStatus, Status, StatusBase
6
7
 
7
8
  from dodal.devices.detector import DetectorParams, TriggerMode
8
9
  from dodal.devices.eiger_odin import EigerOdin
@@ -12,10 +13,6 @@ from dodal.log import LOGGER
12
13
 
13
14
  FREE_RUN_MAX_IMAGES = 1000000
14
15
 
15
- # TODO present for testing purposes, remove
16
- TEST_1169_FIX = True
17
- TEST_1169_INJECT = False
18
-
19
16
 
20
17
  class InternalEigerTriggerMode(Enum):
21
18
  INTERNAL_SERIES = 0
@@ -27,6 +24,7 @@ class InternalEigerTriggerMode(Enum):
27
24
  class EigerDetector(Device):
28
25
  class ArmingSignal(Signal):
29
26
  def set(self, value, *, timeout=None, settle_time=None, **kwargs):
27
+ assert isinstance(self.parent, EigerDetector)
30
28
  return self.parent.async_stage()
31
29
 
32
30
  do_arm = Component(ArmingSignal)
@@ -38,10 +36,12 @@ class EigerDetector(Device):
38
36
 
39
37
  STALE_PARAMS_TIMEOUT = 60
40
38
  GENERAL_STATUS_TIMEOUT = 10
39
+ # Long timeout for meta file to compensate for filesystem issues
40
+ META_FILE_READY_TIMEOUT = 30
41
41
  ALL_FRAMES_TIMEOUT = 120
42
42
  ARMING_TIMEOUT = 60
43
43
 
44
- filewriters_finished: SubscriptionStatus
44
+ filewriters_finished: StatusBase
45
45
 
46
46
  detector_params: DetectorParams | None = None
47
47
 
@@ -53,17 +53,15 @@ class EigerDetector(Device):
53
53
  cls,
54
54
  params: DetectorParams,
55
55
  name: str = "EigerDetector",
56
- *args,
57
- **kwargs,
58
56
  ):
59
- det = cls(name=name, *args, **kwargs)
57
+ det = cls(name=name)
60
58
  det.set_detector_parameters(params)
61
59
  return det
62
60
 
63
61
  def set_detector_parameters(self, detector_params: DetectorParams):
64
62
  self.detector_params = detector_params
65
63
  if self.detector_params is None:
66
- raise Exception("Parameters for scan must be specified")
64
+ raise ValueError("Parameters for scan must be specified")
67
65
 
68
66
  to_check = [
69
67
  (
@@ -100,7 +98,7 @@ class EigerDetector(Device):
100
98
 
101
99
  def stage(self):
102
100
  self.wait_on_arming_if_started()
103
- if TEST_1169_INJECT or not self.is_armed():
101
+ if not self.is_armed():
104
102
  LOGGER.info("Eiger not armed, arming")
105
103
 
106
104
  self.async_stage().wait(timeout=self.ARMING_TIMEOUT)
@@ -155,7 +153,7 @@ class EigerDetector(Device):
155
153
  def enable_roi_mode(self):
156
154
  return self.change_roi_mode(True)
157
155
 
158
- def change_roi_mode(self, enable: bool) -> Status:
156
+ def change_roi_mode(self, enable: bool) -> StatusBase:
159
157
  assert self.detector_params is not None
160
158
  detector_dimensions = (
161
159
  self.detector_params.detector_size_constants.roi_size_pixels
@@ -206,7 +204,7 @@ class EigerDetector(Device):
206
204
  )
207
205
  return status
208
206
 
209
- def set_odin_pvs(self) -> Status:
207
+ def set_odin_pvs(self) -> StatusBase:
210
208
  assert self.detector_params is not None
211
209
  file_prefix = self.detector_params.full_filename
212
210
  status = self.odin.file_writer.file_path.set(
@@ -264,7 +262,7 @@ class EigerDetector(Device):
264
262
  status.set_finished()
265
263
  return status
266
264
 
267
- def set_num_triggers_and_captures(self) -> Status:
265
+ def set_num_triggers_and_captures(self) -> StatusBase:
268
266
  """Sets the number of triggers and the number of images for the Eiger to capture
269
267
  during the datacollection. The number of images is the number of images per
270
268
  trigger.
@@ -295,7 +293,7 @@ class EigerDetector(Device):
295
293
 
296
294
  return status
297
295
 
298
- def _wait_for_odin_status(self) -> Status:
296
+ def _wait_for_odin_status(self) -> StatusBase:
299
297
  self.forward_bit_depth_to_filewriter()
300
298
  await_value(self.odin.meta.active, 1).wait(self.GENERAL_STATUS_TIMEOUT)
301
299
 
@@ -304,18 +302,20 @@ class EigerDetector(Device):
304
302
  )
305
303
  LOGGER.info("Eiger staging: awaiting odin metadata")
306
304
  status &= await_value(
307
- self.odin.meta.ready, 1, timeout=self.GENERAL_STATUS_TIMEOUT
305
+ self.odin.meta.ready, 1, timeout=self.META_FILE_READY_TIMEOUT
308
306
  )
309
307
  return status
310
308
 
311
- def _wait_fan_ready(self) -> Status:
309
+ def _wait_fan_ready(self) -> StatusBase:
312
310
  self.filewriters_finished = self.odin.create_finished_status()
313
311
  LOGGER.info("Eiger staging: awaiting odin fan ready")
314
312
  return await_value(self.odin.fan.ready, 1, self.GENERAL_STATUS_TIMEOUT)
315
313
 
316
314
  def _finish_arm(self) -> Status:
317
315
  LOGGER.info("Eiger staging: Finishing arming")
318
- return Status(done=True, success=True)
316
+ status = Status()
317
+ status.set_finished()
318
+ return status
319
319
 
320
320
  def forward_bit_depth_to_filewriter(self):
321
321
  bit_depth = self.bit_depth.get()
@@ -332,11 +332,16 @@ class EigerDetector(Device):
332
332
 
333
333
  def do_arming_chain(self) -> Status:
334
334
  functions_to_do_arm = []
335
+ assert self.detector_params
335
336
  detector_params: DetectorParams = self.detector_params
336
337
  if detector_params.use_roi_mode:
337
338
  functions_to_do_arm.append(self.enable_roi_mode)
338
339
 
339
340
  arming_sequence_funcs = [
341
+ # If a beam dump occurs after arming the eiger but prior to eiger staging,
342
+ # the odin may timeout which will cause the arming sequence to be retried;
343
+ # if this previously completed successfully we must reset the odin first
344
+ self.odin.stop,
340
345
  lambda: self.change_dev_shm(detector_params.enable_dev_shm),
341
346
  lambda: self.set_detector_threshold(detector_params.expected_energy_ev),
342
347
  self.set_cam_pvs,
@@ -350,11 +355,6 @@ class EigerDetector(Device):
350
355
  self._wait_fan_ready,
351
356
  self._finish_arm,
352
357
  ]
353
- if TEST_1169_FIX:
354
- # If a beam dump occurs after arming the eiger but prior to eiger staging,
355
- # the odin may timeout which will cause the arming sequence to be retried;
356
- # if this previously completed successfully we must reset the odin first
357
- arming_sequence_funcs.insert(0, self.odin.stop)
358
358
 
359
359
  functions_to_do_arm.extend(arming_sequence_funcs)
360
360
 
@@ -1,9 +1,8 @@
1
- from typing import List, Tuple
2
-
1
+ # type: ignore # Eiger will soon be ophyd-async https://github.com/DiamondLightSource/dodal/issues/700
3
2
  from ophyd import Component, Device, EpicsSignal, EpicsSignalRO, EpicsSignalWithRBV
4
3
  from ophyd.areadetector.plugins import HDF5Plugin_V22
5
4
  from ophyd.sim import NullStatus
6
- from ophyd.status import Status, SubscriptionStatus
5
+ from ophyd.status import StatusBase
7
6
 
8
7
  from dodal.devices.status import await_value
9
8
 
@@ -59,12 +58,12 @@ class OdinNodesStatus(Device):
59
58
  node_3 = Component(OdinNode, "OD4:")
60
59
 
61
60
  @property
62
- def nodes(self) -> List[OdinNode]:
61
+ def nodes(self) -> list[OdinNode]:
63
62
  return [self.node_0, self.node_1, self.node_2, self.node_3]
64
63
 
65
64
  def check_node_frames_from_attr(
66
65
  self, node_get_func, error_message_verb: str
67
- ) -> Tuple[bool, str]:
66
+ ) -> tuple[bool, str]:
68
67
  nodes_frames_values = [0] * len(self.nodes)
69
68
  frames_details = []
70
69
  for node_number, node_pv in enumerate(self.nodes):
@@ -75,17 +74,17 @@ class OdinNodesStatus(Device):
75
74
  bad_frames = any(v != 0 for v in nodes_frames_values)
76
75
  return bad_frames, "\n".join(frames_details)
77
76
 
78
- def check_frames_timed_out(self) -> Tuple[bool, str]:
77
+ def check_frames_timed_out(self) -> tuple[bool, str]:
79
78
  return self.check_node_frames_from_attr(
80
79
  lambda node: node.frames_timed_out.get(), "timed out"
81
80
  )
82
81
 
83
- def check_frames_dropped(self) -> Tuple[bool, str]:
82
+ def check_frames_dropped(self) -> tuple[bool, str]:
84
83
  return self.check_node_frames_from_attr(
85
84
  lambda node: node.frames_dropped.get(), "dropped"
86
85
  )
87
86
 
88
- def get_error_state(self) -> Tuple[bool, str]:
87
+ def get_error_state(self) -> tuple[bool, str]:
89
88
  is_error = []
90
89
  error_messages = []
91
90
  for node_number, node_pv in enumerate(self.nodes):
@@ -99,7 +98,7 @@ class OdinNodesStatus(Device):
99
98
 
100
99
  def get_init_state(self) -> bool:
101
100
  is_initialised = []
102
- for node_number, node_pv in enumerate(self.nodes):
101
+ for node_pv in self.nodes:
103
102
  is_initialised.append(node_pv.fr_initialised.get())
104
103
  is_initialised.append(node_pv.fp_initialised.get())
105
104
  return all(is_initialised)
@@ -120,7 +119,7 @@ class EigerOdin(Device):
120
119
  meta = Component(OdinMetaListener, "OD:META:")
121
120
  nodes = Component(OdinNodesStatus, "")
122
121
 
123
- def create_finished_status(self) -> SubscriptionStatus:
122
+ def create_finished_status(self) -> StatusBase:
124
123
  writing_finished = await_value(self.meta.ready, 0)
125
124
  for node_pv in self.nodes.nodes:
126
125
  writing_finished &= await_value(node_pv.writing, 0)
@@ -132,7 +131,7 @@ class EigerOdin(Device):
132
131
  frames_timed_out, frames_timed_out_details = self.nodes.check_frames_timed_out()
133
132
 
134
133
  if not is_initialised:
135
- raise Exception(error_message)
134
+ raise RuntimeError(error_message)
136
135
  if frames_dropped:
137
136
  self.log.error(f"Frames dropped: {frames_dropped_details}")
138
137
  if frames_timed_out:
@@ -140,7 +139,7 @@ class EigerOdin(Device):
140
139
 
141
140
  return is_initialised and not frames_dropped and not frames_timed_out
142
141
 
143
- def check_odin_initialised(self) -> Tuple[bool, str]:
142
+ def check_odin_initialised(self) -> tuple[bool, str]:
144
143
  is_error_state, error_messages = self.nodes.get_error_state()
145
144
  to_check = [
146
145
  (not self.fan.consumers_connected.get(), "EigerFan is not connected"),
@@ -157,7 +156,7 @@ class EigerOdin(Device):
157
156
 
158
157
  return not errors, "\n".join(errors)
159
158
 
160
- def stop(self) -> Status:
159
+ def stop(self) -> StatusBase:
161
160
  """Stop odin manually"""
162
161
  status = self.file_writer.capture.set(0)
163
162
  status &= self.meta.stop_writing.set(1)
@@ -126,7 +126,7 @@ class GridScanParamsCommon(AbstractExperimentWithBeamParams):
126
126
  :return: The motor position this corresponds to.
127
127
  :raises: IndexError if the desired position is outside the grid."""
128
128
  for position, axis in zip(
129
- grid_position, [self.x_axis, self.y_axis, self.z_axis]
129
+ grid_position, [self.x_axis, self.y_axis, self.z_axis], strict=False
130
130
  ):
131
131
  if not axis.is_within(position):
132
132
  raise IndexError(f"{grid_position} is outside the bounds of the grid")
@@ -191,9 +191,10 @@ class MotionProgram(Device):
191
191
  class ExpectedImages(SignalR[int]):
192
192
  def __init__(self, parent: "FastGridScanCommon") -> None:
193
193
  super().__init__(SoftSignalBackend(int))
194
- self.parent: "FastGridScanCommon" = parent
194
+ self.parent = parent
195
195
 
196
- async def get_value(self):
196
+ async def get_value(self, cached: bool | None = None):
197
+ assert isinstance(self.parent, FastGridScanCommon)
197
198
  x = await self.parent.x_steps.get_value()
198
199
  y = await self.parent.y_steps.get_value()
199
200
  z = await self.parent.z_steps.get_value()
@@ -1,9 +1,18 @@
1
- from ophyd import Component as Cpt
2
- from ophyd import Device, EpicsSignal
1
+ from enum import Enum
3
2
 
3
+ from ophyd_async.core import StandardReadable
4
+ from ophyd_async.epics.signal import epics_signal_r
4
5
 
5
- class FluorescenceDetector(Device):
6
+
7
+ class FluorescenceDetectorControlState(Enum):
6
8
  OUT = 0
7
9
  IN = 1
8
10
 
9
- pos = Cpt(EpicsSignal, "-EA-FLU-01:CTRL")
11
+
12
+ class FluorescenceDetector(StandardReadable):
13
+ def __init__(self, prefix: str, name: str = ""):
14
+ with self.add_children_as_readables():
15
+ self.pos = epics_signal_r(
16
+ FluorescenceDetectorControlState, prefix + "-EA-FLU-01:CTRL"
17
+ )
18
+ super().__init__(name)
@@ -1,12 +1,18 @@
1
- from enum import Enum, IntEnum
2
- from typing import Any
3
-
4
- from ophyd import Component, Device, EpicsSignal
5
- from ophyd.status import Status, StatusBase
6
- from ophyd_async.core import StandardReadable
1
+ from enum import Enum
2
+
3
+ from ophyd_async.core import (
4
+ AsyncStatus,
5
+ ConfigSignal,
6
+ Device,
7
+ DeviceVector,
8
+ HintedSignal,
9
+ StandardReadable,
10
+ observe_value,
11
+ )
7
12
  from ophyd_async.core.signal import soft_signal_r_and_setter
8
13
  from ophyd_async.epics.motion import Motor
9
14
  from ophyd_async.epics.signal import (
15
+ epics_signal_r,
10
16
  epics_signal_rw,
11
17
  epics_signal_x,
12
18
  )
@@ -32,11 +38,11 @@ class MirrorStripe(str, Enum):
32
38
  PLATINUM = "Platinum"
33
39
 
34
40
 
35
- class MirrorVoltageDemand(IntEnum):
36
- N_A = 0
37
- OK = 1
38
- FAIL = 2
39
- SLEW = 3
41
+ class MirrorVoltageDemand(str, Enum):
42
+ N_A = "N/A"
43
+ OK = "OK"
44
+ FAIL = "FAIL"
45
+ SLEW = "SLEW"
40
46
 
41
47
 
42
48
  class MirrorVoltageDevice(Device):
@@ -44,14 +50,16 @@ class MirrorVoltageDevice(Device):
44
50
  the demanded voltage setpoint is accepted, without blocking the caller as this process can take significant time.
45
51
  """
46
52
 
47
- _actual_v: EpicsSignal = Component(EpicsSignal, "R")
48
- _setpoint_v: EpicsSignal = Component(EpicsSignal, "D")
49
- _demand_accepted: EpicsSignal = Component(EpicsSignal, "DSEV")
53
+ def __init__(self, name: str = "", prefix: str = ""):
54
+ self._actual_v = epics_signal_r(int, prefix + "R")
55
+ self._setpoint_v = epics_signal_rw(int, prefix + "D")
56
+ self._demand_accepted = epics_signal_r(MirrorVoltageDemand, prefix + "DSEV")
57
+ super().__init__(name=name)
50
58
 
51
- def set(self, value, *args, **kwargs) -> StatusBase:
59
+ @AsyncStatus.wrap
60
+ async def set(self, value, *args, **kwargs):
52
61
  """Combine the following operations into a single set:
53
62
  1. apply the value to the setpoint PV
54
- 2. Return to the caller with a Status future
55
63
  3. Wait until demand is accepted
56
64
  4. When either demand is accepted or DEFAULT_SETTLE_TIME expires, signal the result on the Status
57
65
  """
@@ -59,66 +67,60 @@ class MirrorVoltageDevice(Device):
59
67
  setpoint_v = self._setpoint_v
60
68
  demand_accepted = self._demand_accepted
61
69
 
62
- if demand_accepted.get() != MirrorVoltageDemand.OK:
70
+ if await demand_accepted.get_value() != MirrorVoltageDemand.OK:
63
71
  raise AssertionError(
64
72
  f"Attempted to set {setpoint_v.name} when demand is not accepted."
65
73
  )
66
74
 
67
- if setpoint_v.get() == value:
75
+ if await setpoint_v.get_value() == value:
68
76
  LOGGER.debug(f"{setpoint_v.name} already at {value} - skipping set")
69
- return Status(success=True, done=True)
77
+ return
70
78
 
71
79
  LOGGER.debug(f"setting {setpoint_v.name} to {value}")
72
- demand_accepted_status = Status(self, DEFAULT_SETTLE_TIME_S)
73
-
74
- subscription: dict[str, Any] = {"handle": None}
75
-
76
- def demand_check_callback(old_value, value, **kwargs):
77
- LOGGER.debug(f"Got event old={old_value} new={value} for {setpoint_v.name}")
78
- if old_value != MirrorVoltageDemand.OK and value == MirrorVoltageDemand.OK:
79
- LOGGER.debug(f"Demand accepted for {setpoint_v.name}")
80
- subs_handle = subscription.pop("handle", None)
81
- if subs_handle is None:
82
- raise AssertionError("Demand accepted before set attempted")
83
- demand_accepted.unsubscribe(subs_handle)
84
80
 
85
- demand_accepted_status.set_finished()
86
- # else timeout handled by parent demand_accepted_status
81
+ # Register an observer up front to ensure we don't miss events after we
82
+ # perform the set
83
+ demand_accepted_iterator = observe_value(
84
+ demand_accepted, timeout=DEFAULT_SETTLE_TIME_S
85
+ )
86
+ # discard the current value (OK) so we can await a subsequent change
87
+ await anext(demand_accepted_iterator)
88
+ await setpoint_v.set(value)
89
+
90
+ # The set should always change to SLEW regardless of whether we are
91
+ # already at the set point, then change back to OK/FAIL depending on
92
+ # success
93
+ accepted_value = await anext(demand_accepted_iterator)
94
+ assert accepted_value == MirrorVoltageDemand.SLEW
95
+ LOGGER.debug(
96
+ f"Demand not accepted for {setpoint_v.name}, waiting for acceptance..."
97
+ )
98
+ while MirrorVoltageDemand.SLEW == (
99
+ accepted_value := await anext(demand_accepted_iterator)
100
+ ):
101
+ pass
87
102
 
88
- subscription["handle"] = demand_accepted.subscribe(demand_check_callback)
89
- setpoint_status = setpoint_v.set(value)
90
- status = setpoint_status & demand_accepted_status
91
- return status
103
+ if accepted_value != MirrorVoltageDemand.OK:
104
+ raise AssertionError(
105
+ f"Voltage slew failed for {setpoint_v.name}, new state={accepted_value}"
106
+ )
92
107
 
93
108
 
94
- class VFMMirrorVoltages(Device):
95
- def __init__(self, *args, daq_configuration_path: str, **kwargs):
96
- super().__init__(*args, **kwargs)
109
+ class VFMMirrorVoltages(StandardReadable):
110
+ def __init__(
111
+ self, name: str, prefix: str, *args, daq_configuration_path: str, **kwargs
112
+ ):
97
113
  self.voltage_lookup_table_path = (
98
114
  daq_configuration_path + "/json/mirrorFocus.json"
99
115
  )
100
-
101
- _channel14_voltage_device = Component(MirrorVoltageDevice, "BM:V14")
102
- _channel15_voltage_device = Component(MirrorVoltageDevice, "BM:V15")
103
- _channel16_voltage_device = Component(MirrorVoltageDevice, "BM:V16")
104
- _channel17_voltage_device = Component(MirrorVoltageDevice, "BM:V17")
105
- _channel18_voltage_device = Component(MirrorVoltageDevice, "BM:V18")
106
- _channel19_voltage_device = Component(MirrorVoltageDevice, "BM:V19")
107
- _channel20_voltage_device = Component(MirrorVoltageDevice, "BM:V20")
108
- _channel21_voltage_device = Component(MirrorVoltageDevice, "BM:V21")
109
-
110
- @property
111
- def voltage_channels(self) -> list[MirrorVoltageDevice]:
112
- return [
113
- self._channel14_voltage_device,
114
- self._channel15_voltage_device,
115
- self._channel16_voltage_device,
116
- self._channel17_voltage_device,
117
- self._channel18_voltage_device,
118
- self._channel19_voltage_device,
119
- self._channel20_voltage_device,
120
- self._channel21_voltage_device,
121
- ]
116
+ with self.add_children_as_readables():
117
+ self.voltage_channels = DeviceVector(
118
+ {
119
+ i - 14: MirrorVoltageDevice(prefix=f"{prefix}BM:V{i}")
120
+ for i in range(14, 22)
121
+ }
122
+ )
123
+ super().__init__(*args, name=name, **kwargs)
122
124
 
123
125
 
124
126
  class FocusingMirror(StandardReadable):
@@ -144,10 +146,8 @@ class FocusingMirror(StandardReadable):
144
146
  # regardless of orientation of the mirror
145
147
  self.incident_angle = Motor(prefix + "PITCH")
146
148
 
147
- self.set_readable_signals(
148
- read=[self.incident_angle.user_readback],
149
- config=[self.type],
150
- )
149
+ self.add_readables([self.incident_angle.user_readback], wrapper=HintedSignal)
150
+ self.add_readables([self.type], wrapper=ConfigSignal)
151
151
  super().__init__(name)
152
152
 
153
153
 
@@ -74,20 +74,20 @@ class HutchShutter(StandardReadable, Movable):
74
74
  super().__init__(name)
75
75
 
76
76
  @AsyncStatus.wrap
77
- async def set(self, position_demand: ShutterDemand):
77
+ async def set(self, value: ShutterDemand):
78
78
  interlock_state = await self.interlock.shutter_safe_to_operate()
79
79
  if not interlock_state:
80
80
  raise ShutterNotSafeToOperateError(
81
81
  "The hutch has not been locked, not operating shutter."
82
82
  )
83
- if position_demand == ShutterDemand.OPEN:
83
+ if value == ShutterDemand.OPEN:
84
84
  await self.control.set(ShutterDemand.RESET, wait=True)
85
- await self.control.set(position_demand, wait=True)
85
+ await self.control.set(value, wait=True)
86
86
  return await wait_for_value(
87
87
  self.status, match=ShutterState.OPEN, timeout=DEFAULT_TIMEOUT
88
88
  )
89
89
  else:
90
- await self.control.set(position_demand, wait=True)
90
+ await self.control.set(value, wait=True)
91
91
  return await wait_for_value(
92
92
  self.status, match=ShutterState.CLOSED, timeout=DEFAULT_TIMEOUT
93
93
  )
dodal/devices/i22/dcm.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import time
2
+ from collections.abc import Sequence
2
3
  from dataclasses import dataclass
3
- from typing import Dict, Literal, Sequence
4
+ from typing import Literal
4
5
 
5
6
  from bluesky.protocols import Reading
6
7
  from event_model.documents.event_descriptor import DataKey
@@ -127,7 +128,7 @@ class DoubleCrystalMonochromator(StandardReadable):
127
128
 
128
129
  super().__init__(name)
129
130
 
130
- async def describe(self) -> Dict[str, DataKey]:
131
+ async def describe(self) -> dict[str, DataKey]:
131
132
  default_describe = await super().describe()
132
133
  return {
133
134
  f"{self.name}-wavelength": DataKey(
@@ -139,7 +140,7 @@ class DoubleCrystalMonochromator(StandardReadable):
139
140
  **default_describe,
140
141
  }
141
142
 
142
- async def read(self) -> Dict[str, Reading]:
143
+ async def read(self) -> dict[str, Reading]:
143
144
  default_reading = await super().read()
144
145
  energy: float = default_reading[f"{self.name}-energy"]["value"]
145
146
  if energy > 0.0:
@@ -1,9 +1,9 @@
1
1
  import asyncio
2
2
  import time
3
3
  from enum import Enum
4
- from typing import Dict
5
4
 
6
- from bluesky.protocols import DataKey, Reading
5
+ from bluesky.protocols import Reading
6
+ from event_model import DataKey
7
7
  from ophyd_async.core import ConfigSignal, StandardReadable, soft_signal_r_and_setter
8
8
  from ophyd_async.core.device import DeviceVector
9
9
  from ophyd_async.epics.signal import epics_signal_r
@@ -74,7 +74,7 @@ class FSwitch(StandardReadable):
74
74
 
75
75
  super().__init__(name)
76
76
 
77
- async def describe(self) -> Dict[str, DataKey]:
77
+ async def describe(self) -> dict[str, DataKey]:
78
78
  default_describe = await super().describe()
79
79
  return {
80
80
  FSwitch.NUM_LENSES_FIELD_NAME: DataKey(
@@ -83,7 +83,7 @@ class FSwitch(StandardReadable):
83
83
  **default_describe,
84
84
  }
85
85
 
86
- async def read(self) -> Dict[str, Reading]:
86
+ async def read(self) -> dict[str, Reading]:
87
87
  result = await asyncio.gather(
88
88
  *(filter.get_value() for filter in self.filters.values())
89
89
  )