dls-dodal 1.39.0__py3-none-any.whl → 1.41.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 (61) hide show
  1. {dls_dodal-1.39.0.dist-info → dls_dodal-1.41.0.dist-info}/METADATA +5 -3
  2. {dls_dodal-1.39.0.dist-info → dls_dodal-1.41.0.dist-info}/RECORD +61 -52
  3. {dls_dodal-1.39.0.dist-info → dls_dodal-1.41.0.dist-info}/WHEEL +1 -1
  4. dodal/_version.py +9 -4
  5. dodal/beamlines/__init__.py +2 -0
  6. dodal/beamlines/adsim.py +3 -2
  7. dodal/beamlines/b01_1.py +3 -3
  8. dodal/beamlines/i03.py +141 -292
  9. dodal/beamlines/i04.py +112 -198
  10. dodal/beamlines/i13_1.py +5 -4
  11. dodal/beamlines/i18.py +124 -0
  12. dodal/beamlines/i19_1.py +74 -0
  13. dodal/beamlines/i19_2.py +61 -0
  14. dodal/beamlines/i20_1.py +37 -22
  15. dodal/beamlines/i22.py +7 -7
  16. dodal/beamlines/i23.py +8 -11
  17. dodal/beamlines/i24.py +100 -145
  18. dodal/beamlines/p38.py +84 -220
  19. dodal/beamlines/p45.py +5 -4
  20. dodal/beamlines/training_rig.py +4 -4
  21. dodal/common/beamlines/beamline_utils.py +2 -3
  22. dodal/common/beamlines/device_helpers.py +3 -1
  23. dodal/devices/aperturescatterguard.py +150 -64
  24. dodal/devices/apple2_undulator.py +89 -114
  25. dodal/devices/attenuator/attenuator.py +1 -1
  26. dodal/devices/backlight.py +1 -1
  27. dodal/devices/bimorph_mirror.py +2 -2
  28. dodal/devices/eiger.py +3 -2
  29. dodal/devices/fast_grid_scan.py +26 -19
  30. dodal/devices/hutch_shutter.py +26 -13
  31. dodal/devices/i10/i10_apple2.py +3 -3
  32. dodal/devices/i10/rasor/rasor_scaler_cards.py +4 -4
  33. dodal/devices/i13_1/merlin.py +4 -3
  34. dodal/devices/i13_1/merlin_controller.py +2 -7
  35. dodal/devices/i18/KBMirror.py +19 -0
  36. dodal/devices/i18/diode.py +17 -0
  37. dodal/devices/i18/table.py +14 -0
  38. dodal/devices/i18/thor_labs_stage.py +12 -0
  39. dodal/devices/i19/__init__.py +0 -0
  40. dodal/devices/i19/shutter.py +57 -0
  41. dodal/devices/i22/nxsas.py +4 -4
  42. dodal/devices/i24/pmac.py +2 -2
  43. dodal/devices/motors.py +2 -2
  44. dodal/devices/oav/oav_detector.py +10 -19
  45. dodal/devices/pressure_jump_cell.py +43 -19
  46. dodal/devices/robot.py +31 -12
  47. dodal/devices/tetramm.py +8 -3
  48. dodal/devices/thawer.py +4 -4
  49. dodal/devices/turbo_slit.py +7 -6
  50. dodal/devices/undulator.py +1 -1
  51. dodal/devices/undulator_dcm.py +1 -1
  52. dodal/devices/util/epics_util.py +1 -1
  53. dodal/devices/zebra/zebra.py +4 -3
  54. dodal/devices/zebra/zebra_controlled_shutter.py +1 -1
  55. dodal/devices/zocalo/zocalo_results.py +21 -4
  56. dodal/plan_stubs/wrapped.py +10 -12
  57. dodal/plans/save_panda.py +30 -14
  58. dodal/utils.py +55 -21
  59. {dls_dodal-1.39.0.dist-info → dls_dodal-1.41.0.dist-info}/LICENSE +0 -0
  60. {dls_dodal-1.39.0.dist-info → dls_dodal-1.41.0.dist-info}/entry_points.txt +0 -0
  61. {dls_dodal-1.39.0.dist-info → dls_dodal-1.41.0.dist-info}/top_level.txt +0 -0
@@ -2,7 +2,7 @@ from __future__ import annotations
2
2
 
3
3
  import asyncio
4
4
 
5
- from bluesky.protocols import Movable
5
+ from bluesky.protocols import Movable, Preparable
6
6
  from ophyd_async.core import (
7
7
  AsyncStatus,
8
8
  StandardReadable,
@@ -21,6 +21,15 @@ class InvalidApertureMove(Exception):
21
21
  pass
22
22
 
23
23
 
24
+ class _GDAParamApertureValue(StrictEnum):
25
+ """Maps from a short usable name to the value name in the GDA Beamline parameters"""
26
+
27
+ ROBOT_LOAD = "ROBOT_LOAD"
28
+ SMALL = "SMALL_APERTURE"
29
+ MEDIUM = "MEDIUM_APERTURE"
30
+ LARGE = "LARGE_APERTURE"
31
+
32
+
24
33
  class AperturePosition(BaseModel):
25
34
  """
26
35
  Represents one of the available positions for the Aperture-Scatterguard.
@@ -65,7 +74,7 @@ class AperturePosition(BaseModel):
65
74
 
66
75
  @staticmethod
67
76
  def from_gda_params(
68
- name: ApertureValue,
77
+ name: _GDAParamApertureValue,
69
78
  radius: float,
70
79
  params: GDABeamlineParameters,
71
80
  ) -> AperturePosition:
@@ -80,12 +89,16 @@ class AperturePosition(BaseModel):
80
89
 
81
90
 
82
91
  class ApertureValue(StrictEnum):
83
- """Maps from a short usable name to the value name in the GDA Beamline parameters"""
92
+ """The possible apertures that can be selected.
93
+
94
+ Changing these means changing the external paramter model of Hyperion.
95
+ See https://github.com/DiamondLightSource/mx-bluesky/issues/760
96
+ """
84
97
 
85
- ROBOT_LOAD = "ROBOT_LOAD"
86
98
  SMALL = "SMALL_APERTURE"
87
99
  MEDIUM = "MEDIUM_APERTURE"
88
100
  LARGE = "LARGE_APERTURE"
101
+ OUT_OF_BEAM = "Out of beam"
89
102
 
90
103
  def __str__(self):
91
104
  return self.name.capitalize()
@@ -95,22 +108,53 @@ def load_positions_from_beamline_parameters(
95
108
  params: GDABeamlineParameters,
96
109
  ) -> dict[ApertureValue, AperturePosition]:
97
110
  return {
98
- ApertureValue.ROBOT_LOAD: AperturePosition.from_gda_params(
99
- ApertureValue.ROBOT_LOAD, 0, params
111
+ ApertureValue.OUT_OF_BEAM: AperturePosition.from_gda_params(
112
+ _GDAParamApertureValue.ROBOT_LOAD, 0, params
100
113
  ),
101
114
  ApertureValue.SMALL: AperturePosition.from_gda_params(
102
- ApertureValue.SMALL, 20, params
115
+ _GDAParamApertureValue.SMALL, 20, params
103
116
  ),
104
117
  ApertureValue.MEDIUM: AperturePosition.from_gda_params(
105
- ApertureValue.MEDIUM, 50, params
118
+ _GDAParamApertureValue.MEDIUM, 50, params
106
119
  ),
107
120
  ApertureValue.LARGE: AperturePosition.from_gda_params(
108
- ApertureValue.LARGE, 100, params
121
+ _GDAParamApertureValue.LARGE, 100, params
109
122
  ),
110
123
  }
111
124
 
112
125
 
113
- class ApertureScatterguard(StandardReadable, Movable):
126
+ class ApertureScatterguard(StandardReadable, Movable[ApertureValue], Preparable):
127
+ """Move the aperture and scatterguard assembly in a safe way. There are two ways to
128
+ interact with the device depending on if you want simplicity or move flexibility.
129
+
130
+ Examples:
131
+ The simple interface is using::
132
+
133
+ await aperture_scatterguard.set(ApertureValue.LARGE)
134
+
135
+ This will move the assembly so that the large aperture is in the beam, regardless
136
+ of where the assembly currently is.
137
+
138
+ We may also want to move the assembly out of the beam with::
139
+
140
+ await aperture_scatterguard.set(ApertureValue.OUT_OF_BEAM)
141
+
142
+ Note, to make sure we do this as quickly as possible, the scatterguard will stay
143
+ in the same position relative to the aperture.
144
+
145
+ We may then want to keep the assembly out of the beam whilst asynchronously preparing
146
+ the other axes for the aperture that's to follow::
147
+
148
+ await aperture_scatterguard.prepare(ApertureValue.LARGE)
149
+
150
+ Then, at a later time, move back into the beam::
151
+
152
+ await aperture_scatterguard.set(ApertureValue.LARGE)
153
+
154
+ Given the prepare has been done this move will now be faster as only the y is
155
+ left to move.
156
+ """
157
+
114
158
  def __init__(
115
159
  self,
116
160
  loaded_positions: dict[ApertureValue, AperturePosition],
@@ -135,6 +179,7 @@ class ApertureScatterguard(StandardReadable, Movable):
135
179
  self.radius,
136
180
  ],
137
181
  )
182
+
138
183
  with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
139
184
  self.selected_aperture = create_hardware_backed_soft_signal(
140
185
  ApertureValue, self._get_current_aperture_position
@@ -142,15 +187,85 @@ class ApertureScatterguard(StandardReadable, Movable):
142
187
 
143
188
  super().__init__(name)
144
189
 
145
- def get_position_from_gda_aperture_name(
146
- self, gda_aperture_name: str
147
- ) -> ApertureValue:
148
- return ApertureValue(gda_aperture_name)
149
-
150
190
  @AsyncStatus.wrap
151
191
  async def set(self, value: ApertureValue):
192
+ """This set will move the aperture into the beam or move the whole assembly out"""
193
+
152
194
  position = self._loaded_positions[value]
153
- await self._safe_move_within_datacollection_range(position, value)
195
+ await self._check_safe_to_move(position.aperture_z)
196
+
197
+ if value == ApertureValue.OUT_OF_BEAM:
198
+ out_y = self._loaded_positions[ApertureValue.OUT_OF_BEAM].aperture_y
199
+ await self.aperture.y.set(out_y)
200
+ else:
201
+ await self._safe_move_whilst_in_beam(position)
202
+
203
+ async def _check_safe_to_move(self, expected_z_position: float):
204
+ """The assembly is moved (in z) to be under the table when the beamline is not
205
+ in use. If we try and move whilst in the incorrect Z position we will collide
206
+ with the table.
207
+
208
+ Additionally, because there are so many collision possibilities in the device we
209
+ throw an error if any of the axes are already moving.
210
+ """
211
+ current_ap_z = await self.aperture.z.user_readback.get_value()
212
+ diff_on_z = abs(current_ap_z - expected_z_position)
213
+ aperture_z_tolerance = self._tolerances.aperture_z
214
+ if diff_on_z > aperture_z_tolerance:
215
+ raise InvalidApertureMove(
216
+ f"Current aperture z ({current_ap_z}), outside of tolerance ({aperture_z_tolerance}) from target ({expected_z_position})."
217
+ )
218
+
219
+ all_axes = [
220
+ self.aperture.x,
221
+ self.aperture.y,
222
+ self.aperture.z,
223
+ self.scatterguard.x,
224
+ self.scatterguard.y,
225
+ ]
226
+ for axis in all_axes:
227
+ axis_stationary = await axis.motor_done_move.get_value()
228
+ if not axis_stationary:
229
+ raise InvalidApertureMove(
230
+ f"{axis.name} is still moving. Wait for it to finish before"
231
+ "triggering another move."
232
+ )
233
+
234
+ async def _safe_move_whilst_in_beam(self, position: AperturePosition):
235
+ """
236
+ Move the aperture and scatterguard combo safely to a new position.
237
+ See https://github.com/DiamondLightSource/hyperion/wiki/Aperture-Scatterguard-Collisions
238
+ for why this is required. TLDR is that we have a collision at the top of y so we need
239
+ to make sure we move the assembly down before we move the scatterguard up.
240
+ """
241
+ current_ap_y = await self.aperture.y.user_readback.get_value()
242
+
243
+ aperture_x, aperture_y, aperture_z, scatterguard_x, scatterguard_y = (
244
+ position.values
245
+ )
246
+
247
+ if aperture_y > current_ap_y:
248
+ # Assembly needs to move up so move the scatterguard down first
249
+ await asyncio.gather(
250
+ self.scatterguard.x.set(scatterguard_x),
251
+ self.scatterguard.y.set(scatterguard_y),
252
+ )
253
+ await asyncio.gather(
254
+ self.aperture.x.set(aperture_x),
255
+ self.aperture.y.set(aperture_y),
256
+ self.aperture.z.set(aperture_z),
257
+ )
258
+ else:
259
+ await asyncio.gather(
260
+ self.aperture.x.set(aperture_x),
261
+ self.aperture.y.set(aperture_y),
262
+ self.aperture.z.set(aperture_z),
263
+ )
264
+
265
+ await asyncio.gather(
266
+ self.scatterguard.x.set(scatterguard_x),
267
+ self.scatterguard.y.set(scatterguard_y),
268
+ )
154
269
 
155
270
  @AsyncStatus.wrap
156
271
  async def _set_raw_unsafe(self, position: AperturePosition):
@@ -167,6 +282,11 @@ class ApertureScatterguard(StandardReadable, Movable):
167
282
  self.scatterguard.y.set(scatterguard_y),
168
283
  )
169
284
 
285
+ async def _is_out_of_beam(self) -> bool:
286
+ current_ap_y = await self.aperture.y.user_readback.get_value()
287
+ out_ap_y = self._loaded_positions[ApertureValue.OUT_OF_BEAM].aperture_y
288
+ return current_ap_y <= out_ap_y + self._tolerances.aperture_y
289
+
170
290
  async def _get_current_aperture_position(self) -> ApertureValue:
171
291
  """
172
292
  Returns the current aperture position using readback values
@@ -174,16 +294,14 @@ class ApertureScatterguard(StandardReadable, Movable):
174
294
  mini aperture y <= ROBOT_LOAD.location.aperture_y + tolerance.
175
295
  If no position is found then raises InvalidApertureMove.
176
296
  """
177
- current_ap_y = await self.aperture.y.user_readback.get_value(cached=False)
178
- robot_load_ap_y = self._loaded_positions[ApertureValue.ROBOT_LOAD].aperture_y
179
297
  if await self.aperture.large.get_value(cached=False) == 1:
180
298
  return ApertureValue.LARGE
181
299
  elif await self.aperture.medium.get_value(cached=False) == 1:
182
300
  return ApertureValue.MEDIUM
183
301
  elif await self.aperture.small.get_value(cached=False) == 1:
184
302
  return ApertureValue.SMALL
185
- elif current_ap_y <= robot_load_ap_y + self._tolerances.aperture_y:
186
- return ApertureValue.ROBOT_LOAD
303
+ elif await self._is_out_of_beam():
304
+ return ApertureValue.OUT_OF_BEAM
187
305
 
188
306
  raise InvalidApertureMove("Current aperture/scatterguard state unrecognised")
189
307
 
@@ -191,56 +309,24 @@ class ApertureScatterguard(StandardReadable, Movable):
191
309
  current_value = await self._get_current_aperture_position()
192
310
  return self._loaded_positions[current_value].radius
193
311
 
194
- async def _safe_move_within_datacollection_range(
195
- self, position: AperturePosition, value: ApertureValue
196
- ):
197
- """
198
- Move the aperture and scatterguard combo safely to a new position.
199
- See https://github.com/DiamondLightSource/hyperion/wiki/Aperture-Scatterguard-Collisions
200
- for why this is required.
201
- """
202
- assert self._loaded_positions is not None
203
-
204
- ap_z_in_position = await self.aperture.z.motor_done_move.get_value()
205
- if not ap_z_in_position:
206
- raise InvalidApertureMove(
207
- "ApertureScatterguard z is still moving. Wait for it to finish "
208
- "before triggering another move."
209
- )
312
+ @AsyncStatus.wrap
313
+ async def prepare(self, value: ApertureValue):
314
+ """Moves the assembly to the position for the specified aperture, whilst keeping
315
+ it out of the beam if it already is so.
210
316
 
211
- current_ap_z = await self.aperture.z.user_readback.get_value()
212
- diff_on_z = abs(current_ap_z - position.aperture_z)
213
- if diff_on_z > self._tolerances.aperture_z:
214
- raise InvalidApertureMove(
215
- "ApertureScatterguard safe move is not yet defined for positions "
216
- "outside of LARGE, MEDIUM, SMALL, ROBOT_LOAD. "
217
- f"Current aperture z ({current_ap_z}), outside of tolerance ({self._tolerances.aperture_z}) from target ({position.aperture_z})."
317
+ Moving the assembly whilst out of the beam has no collision risk so we can just
318
+ move all the motors together.
319
+ """
320
+ if await self._is_out_of_beam():
321
+ aperture_x, _, aperture_z, scatterguard_x, scatterguard_y = (
322
+ self._loaded_positions[value].values
218
323
  )
219
324
 
220
- current_ap_y = await self.aperture.y.user_readback.get_value()
221
-
222
- aperture_x, aperture_y, aperture_z, scatterguard_x, scatterguard_y = (
223
- position.values
224
- )
225
-
226
- if position.aperture_y > current_ap_y:
227
- await asyncio.gather(
228
- self.scatterguard.x.set(scatterguard_x),
229
- self.scatterguard.y.set(scatterguard_y),
230
- )
231
- await asyncio.gather(
232
- self.aperture.x.set(aperture_x),
233
- self.aperture.y.set(aperture_y),
234
- self.aperture.z.set(aperture_z),
235
- )
236
- else:
237
325
  await asyncio.gather(
238
326
  self.aperture.x.set(aperture_x),
239
- self.aperture.y.set(aperture_y),
240
327
  self.aperture.z.set(aperture_z),
241
- )
242
-
243
- await asyncio.gather(
244
328
  self.scatterguard.x.set(scatterguard_x),
245
329
  self.scatterguard.y.set(scatterguard_y),
246
330
  )
331
+ else:
332
+ await self.set(value)
@@ -1,13 +1,15 @@
1
1
  import abc
2
2
  import asyncio
3
3
  from dataclasses import dataclass
4
- from typing import Any
4
+ from typing import Any, Generic, TypeVar
5
5
 
6
6
  import numpy as np
7
7
  from bluesky.protocols import Movable
8
8
  from ophyd_async.core import (
9
9
  AsyncStatus,
10
10
  Reference,
11
+ SignalR,
12
+ SignalW,
11
13
  StandardReadable,
12
14
  StandardReadableFormat,
13
15
  StrictEnum,
@@ -19,6 +21,8 @@ from pydantic import BaseModel, ConfigDict, RootModel
19
21
 
20
22
  from dodal.log import LOGGER
21
23
 
24
+ T = TypeVar("T")
25
+
22
26
 
23
27
  class UndulatorGateStatus(StrictEnum):
24
28
  OPEN = "Open"
@@ -88,7 +92,56 @@ MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
88
92
  MAXIMUM_GAP_MOTOR_POSITION = 100
89
93
 
90
94
 
91
- class UndulatorGap(StandardReadable, Movable):
95
+ async def estimate_motor_timeout(
96
+ setpoint: SignalR, curr_pos: SignalR, velocity: SignalR
97
+ ):
98
+ vel = await velocity.get_value()
99
+ cur_pos = await curr_pos.get_value()
100
+ target_pos = float(await setpoint.get_value())
101
+ return abs((target_pos - cur_pos) * 2.0 / vel) + 1
102
+
103
+
104
+ class SafeUndulatorMover(StandardReadable, Movable[T], Generic[T]):
105
+ """A device that will check it's safe to move the undulator before moving it and
106
+ wait for the undulator to be safe again before calling the move complete.
107
+ """
108
+
109
+ def __init__(self, set_move: SignalW, prefix: str, name: str = ""):
110
+ # Gate keeper open when move is requested, closed when move is completed
111
+ self.gate = epics_signal_r(UndulatorGateStatus, prefix + "BLGATE")
112
+
113
+ split_pv = prefix.split("-")
114
+ fault_pv = f"{split_pv[0]}-{split_pv[1]}-STAT-{split_pv[3]}ANYFAULT"
115
+ self.fault = epics_signal_r(float, fault_pv)
116
+ self.set_move = set_move
117
+ super().__init__(name)
118
+
119
+ @AsyncStatus.wrap
120
+ async def set(self, value: T) -> None:
121
+ LOGGER.info(f"Setting {self.name} to {value}")
122
+ await self.raise_if_cannot_move()
123
+ await self._set_demand_positions(value)
124
+ timeout = await self.get_timeout()
125
+ LOGGER.info(f"Moving {self.name} to {value} with timeout = {timeout}")
126
+ await self.set_move.set(value=1, timeout=timeout)
127
+ await wait_for_value(self.gate, UndulatorGateStatus.CLOSE, timeout=timeout)
128
+
129
+ @abc.abstractmethod
130
+ async def _set_demand_positions(self, value: T) -> None:
131
+ """Set the demand positions on the device without actually hitting move."""
132
+
133
+ @abc.abstractmethod
134
+ async def get_timeout(self) -> float:
135
+ """Get the timeout for the move based on an estimate of how long it will take."""
136
+
137
+ async def raise_if_cannot_move(self) -> None:
138
+ if await self.fault.get_value() != 0:
139
+ raise RuntimeError(f"{self.name} is in fault state")
140
+ if await self.gate.get_value() == UndulatorGateStatus.OPEN:
141
+ raise RuntimeError(f"{self.name} is already in motion.")
142
+
143
+
144
+ class UndulatorGap(SafeUndulatorMover[float]):
92
145
  """A device with a collection of epics signals to set Apple 2 undulator gap motion.
93
146
  Only PV used by beamline are added the full list is here:
94
147
  /dls_sw/work/R3.14.12.7/support/insertionDevice/db/IDGapVelocityControl.template
@@ -113,21 +166,17 @@ class UndulatorGap(StandardReadable, Movable):
113
166
  )
114
167
  # Nothing move until this is set to 1 and it will return to 0 when done
115
168
  self.set_move = epics_signal_rw(int, prefix + "BLGSETP")
116
- # Gate keeper open when move is requested, closed when move is completed
117
- self.gate = epics_signal_r(UndulatorGateStatus, prefix + "BLGATE")
169
+
118
170
  # These are gap velocity limit.
119
171
  self.max_velocity = epics_signal_r(float, prefix + "BLGSETVEL.HOPR")
120
172
  self.min_velocity = epics_signal_r(float, prefix + "BLGSETVEL.LOPR")
121
173
  # These are gap limit.
122
174
  self.high_limit_travel = epics_signal_r(float, prefix + "BLGAPMTR.HLM")
123
175
  self.low_limit_travel = epics_signal_r(float, prefix + "BLGAPMTR.LLM")
124
- split_pv = prefix.split("-")
125
- self.fault = epics_signal_r(
126
- float,
127
- f"{split_pv[0]}-{split_pv[1]}-STAT-{split_pv[3]}ANYFAULT",
128
- )
176
+
129
177
  # This is calculated acceleration from speed
130
178
  self.acceleration_time = epics_signal_r(float, prefix + "IDGSETACC")
179
+
131
180
  with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
132
181
  # Unit
133
182
  self.motor_egu = epics_signal_r(str, prefix + "BLGAPMTR.EGU")
@@ -136,32 +185,15 @@ class UndulatorGap(StandardReadable, Movable):
136
185
  with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
137
186
  # Gap readback value
138
187
  self.user_readback = epics_signal_r(float, prefix + "CURRGAPD")
139
- super().__init__(name)
188
+ super().__init__(self.set_move, prefix, name)
140
189
 
141
- @AsyncStatus.wrap
142
- async def set(self, value) -> None:
143
- LOGGER.info(f"Setting {self.name} to {value}")
144
- await self.check_id_status()
145
- await self.user_setpoint.set(value=str(value))
146
- timeout = await self._cal_timeout()
147
- LOGGER.info(f"Moving {self.name} to {value} with timeout = {timeout}")
148
- await self.set_move.set(value=1, timeout=timeout)
149
- await wait_for_value(self.gate, UndulatorGateStatus.CLOSE, timeout=timeout)
150
-
151
- async def _cal_timeout(self) -> float:
152
- vel = await self.velocity.get_value()
153
- cur_pos = await self.user_readback.get_value()
154
- target_pos = float(await self.user_setpoint.get_value())
155
- return abs((target_pos - cur_pos) * 2.0 / vel) + 1
156
-
157
- async def check_id_status(self) -> None:
158
- if await self.fault.get_value() != 0:
159
- raise RuntimeError(f"{self.name} is in fault state")
160
- if await self.gate.get_value() == UndulatorGateStatus.OPEN:
161
- raise RuntimeError(f"{self.name} is already in motion.")
190
+ async def _set_demand_positions(self, value: float) -> None:
191
+ await self.user_setpoint.set(str(value))
162
192
 
163
193
  async def get_timeout(self) -> float:
164
- return await self._cal_timeout()
194
+ return await estimate_motor_timeout(
195
+ self.user_setpoint, self.user_readback, self.velocity
196
+ )
165
197
 
166
198
 
167
199
  class UndulatorPhaseMotor(StandardReadable):
@@ -204,7 +236,7 @@ class UndulatorPhaseMotor(StandardReadable):
204
236
  super().__init__(name=name)
205
237
 
206
238
 
207
- class UndulatorPhaseAxes(StandardReadable, Movable):
239
+ class UndulatorPhaseAxes(SafeUndulatorMover[Apple2PhasesVal]):
208
240
  """
209
241
  A collection of 4 phase Motor to make up the full id phase motion. We are using the diamond pv convention.
210
242
  e.g. top_outer == Q1
@@ -231,66 +263,36 @@ class UndulatorPhaseAxes(StandardReadable, Movable):
231
263
  self.btm_outer = UndulatorPhaseMotor(prefix=prefix, infix=btm_outer)
232
264
  # Nothing move until this is set to 1 and it will return to 0 when done.
233
265
  self.set_move = epics_signal_rw(int, f"{prefix}BL{top_outer}" + "MOVE")
234
- self.gate = epics_signal_r(UndulatorGateStatus, prefix + "BLGATE")
235
- split_pv = prefix.split("-")
236
- temp_pv = f"{split_pv[0]}-{split_pv[1]}-STAT-{split_pv[3]}ANYFAULT"
237
- self.fault = epics_signal_r(float, temp_pv)
238
- super().__init__(name=name)
239
266
 
240
- @AsyncStatus.wrap
241
- async def set(self, value: Apple2PhasesVal) -> None:
242
- LOGGER.info(f"Setting {self.name} to {value}")
243
-
244
- await self.check_id_status()
267
+ super().__init__(self.set_move, prefix, name)
245
268
 
269
+ async def _set_demand_positions(self, value: Apple2PhasesVal) -> None:
246
270
  await asyncio.gather(
247
271
  self.top_outer.user_setpoint.set(value=value.top_outer),
248
272
  self.top_inner.user_setpoint.set(value=value.top_inner),
249
273
  self.btm_inner.user_setpoint.set(value=value.btm_inner),
250
274
  self.btm_outer.user_setpoint.set(value=value.btm_outer),
251
275
  )
252
- timeout = await self._cal_timeout()
253
- await self.set_move.set(value=1, timeout=timeout)
254
- await wait_for_value(self.gate, UndulatorGateStatus.CLOSE, timeout=timeout)
255
276
 
256
- async def _cal_timeout(self) -> float:
277
+ async def get_timeout(self) -> float:
257
278
  """
258
279
  Get all four motor speed, current positions and target positions to calculate required timeout.
259
280
  """
260
- velos = await asyncio.gather(
261
- self.top_outer.velocity.get_value(),
262
- self.top_inner.velocity.get_value(),
263
- self.btm_inner.velocity.get_value(),
264
- self.btm_outer.velocity.get_value(),
265
- )
266
- target_pos = await asyncio.gather(
267
- self.top_outer.user_setpoint_demand_readback.get_value(),
268
- self.top_inner.user_setpoint_demand_readback.get_value(),
269
- self.btm_inner.user_setpoint_demand_readback.get_value(),
270
- self.btm_outer.user_setpoint_demand_readback.get_value(),
271
- )
272
- cur_pos = await asyncio.gather(
273
- self.top_outer.user_setpoint_readback.get_value(),
274
- self.top_inner.user_setpoint_readback.get_value(),
275
- self.btm_inner.user_setpoint_readback.get_value(),
276
- self.btm_outer.user_setpoint_readback.get_value(),
281
+ axes = [self.top_outer, self.top_inner, self.btm_inner, self.btm_outer]
282
+ timeouts = await asyncio.gather(
283
+ *[
284
+ estimate_motor_timeout(
285
+ axis.user_setpoint_demand_readback,
286
+ axis.user_setpoint_readback,
287
+ axis.velocity,
288
+ )
289
+ for axis in axes
290
+ ]
277
291
  )
278
- move_distances = tuple(np.subtract(target_pos, cur_pos))
279
- move_times = np.abs(np.divide(move_distances, velos))
280
- longest_move_time = np.max(move_times)
281
- return longest_move_time * 2 + 1
292
+ return np.max(timeouts)
282
293
 
283
- async def check_id_status(self) -> None:
284
- if await self.fault.get_value() != 0:
285
- raise RuntimeError(f"{self.name} is in fault state")
286
- if await self.gate.get_value() == UndulatorGateStatus.OPEN:
287
- raise RuntimeError(f"{self.name} is already in motion.")
288
-
289
- async def get_timeout(self) -> float:
290
- return await self._cal_timeout()
291
294
 
292
-
293
- class UndulatorJawPhase(StandardReadable, Movable):
295
+ class UndulatorJawPhase(SafeUndulatorMover[float]):
294
296
  """
295
297
  A JawPhase movable, this is use for moving the jaw phase which is use to control the
296
298
  linear arbitrary polarisation but only one some of the beamline.
@@ -308,49 +310,22 @@ class UndulatorJawPhase(StandardReadable, Movable):
308
310
  self.jaw_phase = UndulatorPhaseMotor(prefix=prefix, infix=jaw_phase)
309
311
  # Nothing move until this is set to 1 and it will return to 0 when done
310
312
  self.set_move = epics_signal_rw(int, f"{prefix}BL{move_pv}" + "MOVE")
311
- self.gate = epics_signal_r(UndulatorGateStatus, prefix + "BLGATE")
312
- split_pv = prefix.split("-")
313
- temp_pv = f"{split_pv[0]}-{split_pv[1]}-STAT-{split_pv[3]}ANYFAULT"
314
- self.fault = epics_signal_r(float, temp_pv)
315
- super().__init__(name=name)
316
313
 
317
- @AsyncStatus.wrap
318
- async def set(self, value: float) -> None:
319
- LOGGER.info(f"Setting {self.name} to {value}")
314
+ super().__init__(self.set_move, prefix, name)
320
315
 
321
- await self.check_id_status()
322
-
323
- await asyncio.gather(
324
- self.jaw_phase.user_setpoint.set(value=str(value)),
325
- )
326
- timeout = await self._cal_timeout()
327
- await self.set_move.set(value=1, timeout=timeout)
328
- await wait_for_value(self.gate, UndulatorGateStatus.CLOSE, timeout=timeout)
316
+ async def _set_demand_positions(self, value: float) -> None:
317
+ await self.jaw_phase.user_setpoint.set(value=str(value))
329
318
 
330
- async def _cal_timeout(self) -> float:
319
+ async def get_timeout(self) -> float:
331
320
  """
332
321
  Get motor speed, current position and target position to calculate required timeout.
333
322
  """
334
- velo, target_pos, cur_pos = await asyncio.gather(
335
- self.jaw_phase.velocity.get_value(),
336
- self.jaw_phase.user_setpoint_demand_readback.get_value(),
337
- self.jaw_phase.user_setpoint_readback.get_value(),
323
+ return await estimate_motor_timeout(
324
+ self.jaw_phase.user_setpoint_demand_readback,
325
+ self.jaw_phase.user_setpoint_readback,
326
+ self.jaw_phase.velocity,
338
327
  )
339
328
 
340
- move_distances = target_pos - cur_pos
341
- move_times = np.abs(move_distances / velo)
342
-
343
- return move_times * 2 + 1
344
-
345
- async def check_id_status(self) -> None:
346
- if await self.fault.get_value() != 0:
347
- raise RuntimeError(f"{self.name} is in fault state")
348
- if await self.gate.get_value() == UndulatorGateStatus.OPEN:
349
- raise RuntimeError(f"{self.name} is already in motion.")
350
-
351
- async def get_timeout(self) -> float:
352
- return await self._cal_timeout()
353
-
354
329
 
355
330
  class Apple2(StandardReadable, Movable):
356
331
  """
@@ -437,7 +412,7 @@ class Apple2(StandardReadable, Movable):
437
412
  """
438
413
 
439
414
  # Only need to check gap as the phase motors share both fault and gate with gap.
440
- await self.gap().check_id_status()
415
+ await self.gap().raise_if_cannot_move()
441
416
  await asyncio.gather(
442
417
  self.phase().top_outer.user_setpoint.set(value=value.top_outer),
443
418
  self.phase().top_inner.user_setpoint.set(value=value.top_inner),
@@ -29,7 +29,7 @@ class ReadOnlyAttenuator(StandardReadable):
29
29
  super().__init__(name)
30
30
 
31
31
 
32
- class BinaryFilterAttenuator(ReadOnlyAttenuator, Movable):
32
+ class BinaryFilterAttenuator(ReadOnlyAttenuator, Movable[float]):
33
33
  """The attenuator will insert filters into the beam to reduce its transmission.
34
34
  In this attenuator, each filter can be in one of two states: IN or OUT
35
35
 
@@ -15,7 +15,7 @@ class BacklightPosition(StrictEnum):
15
15
  OUT = "Out"
16
16
 
17
17
 
18
- class Backlight(StandardReadable, Movable):
18
+ class Backlight(StandardReadable, Movable[BacklightPosition]):
19
19
  """Simple device to trigger the pneumatic in/out."""
20
20
 
21
21
  TIME_TO_MOVE_S = 1.0 # Tested using a stopwatch on the beamline 09/2024
@@ -41,7 +41,7 @@ class BimorphMirrorStatus(StrictEnum):
41
41
  ERROR = "Error"
42
42
 
43
43
 
44
- class BimorphMirrorChannel(StandardReadable, Movable, EpicsDevice):
44
+ class BimorphMirrorChannel(StandardReadable, Movable[float], EpicsDevice):
45
45
  """Collection of PVs comprising a single bimorph channel.
46
46
 
47
47
  Attributes:
@@ -66,7 +66,7 @@ class BimorphMirrorChannel(StandardReadable, Movable, EpicsDevice):
66
66
  await self.output_voltage.set(value)
67
67
 
68
68
 
69
- class BimorphMirror(StandardReadable, Movable):
69
+ class BimorphMirror(StandardReadable, Movable[Mapping[int, float]]):
70
70
  """Class to represent CAENels Bimorph Mirrors.
71
71
 
72
72
  Attributes:
dodal/devices/eiger.py CHANGED
@@ -2,6 +2,7 @@
2
2
  from dataclasses import dataclass
3
3
  from enum import Enum
4
4
 
5
+ from bluesky.protocols import Stageable
5
6
  from ophyd import Component, Device, EpicsSignalRO, Signal
6
7
  from ophyd.areadetector.cam import EigerDetectorCam
7
8
  from ophyd.status import AndStatus, Status, StatusBase
@@ -42,7 +43,7 @@ AVAILABLE_TIMEOUTS = {
42
43
  }
43
44
 
44
45
 
45
- class EigerDetector(Device):
46
+ class EigerDetector(Device, Stageable):
46
47
  class ArmingSignal(Signal):
47
48
  def set(self, value, *, timeout=None, settle_time=None, **kwargs):
48
49
  assert isinstance(self.parent, EigerDetector)
@@ -161,7 +162,7 @@ class EigerDetector(Device):
161
162
  status_ok = self.odin.check_and_wait_for_odin_state(
162
163
  self.timeouts.general_status_timeout
163
164
  )
164
- self.disable_roi_mode()
165
+ self.disable_roi_mode().wait(self.timeouts.general_status_timeout)
165
166
  self.disarming_status.set_finished()
166
167
  return status_ok
167
168