dls-dodal 1.38.0__py3-none-any.whl → 1.40.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 (49) hide show
  1. {dls_dodal-1.38.0.dist-info → dls_dodal-1.40.0.dist-info}/METADATA +2 -2
  2. {dls_dodal-1.38.0.dist-info → dls_dodal-1.40.0.dist-info}/RECORD +49 -40
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/__init__.py +2 -0
  5. dodal/beamlines/adsim.py +3 -2
  6. dodal/beamlines/b01_1.py +3 -3
  7. dodal/beamlines/i03.py +144 -285
  8. dodal/beamlines/i04.py +112 -198
  9. dodal/beamlines/i13_1.py +5 -4
  10. dodal/beamlines/i18.py +124 -0
  11. dodal/beamlines/i19_1.py +74 -0
  12. dodal/beamlines/i19_2.py +61 -0
  13. dodal/beamlines/i20_1.py +37 -22
  14. dodal/beamlines/i22.py +7 -7
  15. dodal/beamlines/i24.py +100 -145
  16. dodal/beamlines/p38.py +12 -8
  17. dodal/beamlines/p45.py +5 -4
  18. dodal/beamlines/training_rig.py +4 -4
  19. dodal/common/beamlines/beamline_utils.py +2 -3
  20. dodal/common/beamlines/device_helpers.py +3 -1
  21. dodal/devices/aperturescatterguard.py +150 -64
  22. dodal/devices/apple2_undulator.py +86 -113
  23. dodal/devices/eiger.py +24 -14
  24. dodal/devices/fast_grid_scan.py +29 -20
  25. dodal/devices/hutch_shutter.py +25 -12
  26. dodal/devices/i04/transfocator.py +22 -29
  27. dodal/devices/i10/rasor/rasor_scaler_cards.py +4 -4
  28. dodal/devices/i13_1/merlin.py +4 -3
  29. dodal/devices/i13_1/merlin_controller.py +2 -7
  30. dodal/devices/i18/KBMirror.py +19 -0
  31. dodal/devices/i18/diode.py +17 -0
  32. dodal/devices/i18/table.py +14 -0
  33. dodal/devices/i18/thor_labs_stage.py +12 -0
  34. dodal/devices/i19/__init__.py +0 -0
  35. dodal/devices/i19/shutter.py +57 -0
  36. dodal/devices/i22/nxsas.py +4 -4
  37. dodal/devices/motors.py +2 -2
  38. dodal/devices/oav/oav_detector.py +10 -19
  39. dodal/devices/pressure_jump_cell.py +33 -16
  40. dodal/devices/robot.py +30 -11
  41. dodal/devices/tetramm.py +8 -3
  42. dodal/devices/turbo_slit.py +7 -6
  43. dodal/devices/zocalo/zocalo_results.py +21 -4
  44. dodal/plans/save_panda.py +30 -14
  45. dodal/utils.py +54 -15
  46. {dls_dodal-1.38.0.dist-info → dls_dodal-1.40.0.dist-info}/LICENSE +0 -0
  47. {dls_dodal-1.38.0.dist-info → dls_dodal-1.40.0.dist-info}/WHEEL +0 -0
  48. {dls_dodal-1.38.0.dist-info → dls_dodal-1.40.0.dist-info}/entry_points.txt +0 -0
  49. {dls_dodal-1.38.0.dist-info → dls_dodal-1.40.0.dist-info}/top_level.txt +0 -0
@@ -8,6 +8,8 @@ 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,
@@ -88,7 +90,56 @@ MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
88
90
  MAXIMUM_GAP_MOTOR_POSITION = 100
89
91
 
90
92
 
91
- class UndulatorGap(StandardReadable, Movable):
93
+ async def estimate_motor_timeout(
94
+ setpoint: SignalR, curr_pos: SignalR, velocity: SignalR
95
+ ):
96
+ vel = await velocity.get_value()
97
+ cur_pos = await curr_pos.get_value()
98
+ target_pos = float(await setpoint.get_value())
99
+ return abs((target_pos - cur_pos) * 2.0 / vel) + 1
100
+
101
+
102
+ class SafeUndulatorMover(StandardReadable, Movable):
103
+ """A device that will check it's safe to move the undulator before moving it and
104
+ wait for the undulator to be safe again before calling the move complete.
105
+ """
106
+
107
+ def __init__(self, set_move: SignalW, prefix: str, name: str = ""):
108
+ # Gate keeper open when move is requested, closed when move is completed
109
+ self.gate = epics_signal_r(UndulatorGateStatus, prefix + "BLGATE")
110
+
111
+ split_pv = prefix.split("-")
112
+ fault_pv = f"{split_pv[0]}-{split_pv[1]}-STAT-{split_pv[3]}ANYFAULT"
113
+ self.fault = epics_signal_r(float, fault_pv)
114
+ self.set_move = set_move
115
+ super().__init__(name)
116
+
117
+ @AsyncStatus.wrap
118
+ async def set(self, value) -> None:
119
+ LOGGER.info(f"Setting {self.name} to {value}")
120
+ await self.raise_if_cannot_move()
121
+ await self._set_demand_positions(value)
122
+ timeout = await self.get_timeout()
123
+ LOGGER.info(f"Moving {self.name} to {value} with timeout = {timeout}")
124
+ await self.set_move.set(value=1, timeout=timeout)
125
+ await wait_for_value(self.gate, UndulatorGateStatus.CLOSE, timeout=timeout)
126
+
127
+ @abc.abstractmethod
128
+ async def _set_demand_positions(self, value) -> None:
129
+ """Set the demand positions on the device without actually hitting move."""
130
+
131
+ @abc.abstractmethod
132
+ async def get_timeout(self) -> float:
133
+ """Get the timeout for the move based on an estimate of how long it will take."""
134
+
135
+ async def raise_if_cannot_move(self) -> None:
136
+ if await self.fault.get_value() != 0:
137
+ raise RuntimeError(f"{self.name} is in fault state")
138
+ if await self.gate.get_value() == UndulatorGateStatus.OPEN:
139
+ raise RuntimeError(f"{self.name} is already in motion.")
140
+
141
+
142
+ class UndulatorGap(SafeUndulatorMover):
92
143
  """A device with a collection of epics signals to set Apple 2 undulator gap motion.
93
144
  Only PV used by beamline are added the full list is here:
94
145
  /dls_sw/work/R3.14.12.7/support/insertionDevice/db/IDGapVelocityControl.template
@@ -113,21 +164,17 @@ class UndulatorGap(StandardReadable, Movable):
113
164
  )
114
165
  # Nothing move until this is set to 1 and it will return to 0 when done
115
166
  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")
167
+
118
168
  # These are gap velocity limit.
119
169
  self.max_velocity = epics_signal_r(float, prefix + "BLGSETVEL.HOPR")
120
170
  self.min_velocity = epics_signal_r(float, prefix + "BLGSETVEL.LOPR")
121
171
  # These are gap limit.
122
172
  self.high_limit_travel = epics_signal_r(float, prefix + "BLGAPMTR.HLM")
123
173
  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
- )
174
+
129
175
  # This is calculated acceleration from speed
130
176
  self.acceleration_time = epics_signal_r(float, prefix + "IDGSETACC")
177
+
131
178
  with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
132
179
  # Unit
133
180
  self.motor_egu = epics_signal_r(str, prefix + "BLGAPMTR.EGU")
@@ -136,32 +183,15 @@ class UndulatorGap(StandardReadable, Movable):
136
183
  with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
137
184
  # Gap readback value
138
185
  self.user_readback = epics_signal_r(float, prefix + "CURRGAPD")
139
- super().__init__(name)
140
-
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
186
+ super().__init__(self.set_move, prefix, name)
156
187
 
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.")
188
+ async def _set_demand_positions(self, value) -> None:
189
+ await self.user_setpoint.set(str(value))
162
190
 
163
191
  async def get_timeout(self) -> float:
164
- return await self._cal_timeout()
192
+ return await estimate_motor_timeout(
193
+ self.user_setpoint, self.user_readback, self.velocity
194
+ )
165
195
 
166
196
 
167
197
  class UndulatorPhaseMotor(StandardReadable):
@@ -204,7 +234,7 @@ class UndulatorPhaseMotor(StandardReadable):
204
234
  super().__init__(name=name)
205
235
 
206
236
 
207
- class UndulatorPhaseAxes(StandardReadable, Movable):
237
+ class UndulatorPhaseAxes(SafeUndulatorMover):
208
238
  """
209
239
  A collection of 4 phase Motor to make up the full id phase motion. We are using the diamond pv convention.
210
240
  e.g. top_outer == Q1
@@ -231,66 +261,36 @@ class UndulatorPhaseAxes(StandardReadable, Movable):
231
261
  self.btm_outer = UndulatorPhaseMotor(prefix=prefix, infix=btm_outer)
232
262
  # Nothing move until this is set to 1 and it will return to 0 when done.
233
263
  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
-
240
- @AsyncStatus.wrap
241
- async def set(self, value: Apple2PhasesVal) -> None:
242
- LOGGER.info(f"Setting {self.name} to {value}")
243
264
 
244
- await self.check_id_status()
265
+ super().__init__(self.set_move, prefix, name)
245
266
 
267
+ async def _set_demand_positions(self, value: Apple2PhasesVal) -> None:
246
268
  await asyncio.gather(
247
269
  self.top_outer.user_setpoint.set(value=value.top_outer),
248
270
  self.top_inner.user_setpoint.set(value=value.top_inner),
249
271
  self.btm_inner.user_setpoint.set(value=value.btm_inner),
250
272
  self.btm_outer.user_setpoint.set(value=value.btm_outer),
251
273
  )
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
274
 
256
- async def _cal_timeout(self) -> float:
275
+ async def get_timeout(self) -> float:
257
276
  """
258
277
  Get all four motor speed, current positions and target positions to calculate required timeout.
259
278
  """
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(),
279
+ axes = [self.top_outer, self.top_inner, self.btm_inner, self.btm_outer]
280
+ timeouts = await asyncio.gather(
281
+ *[
282
+ estimate_motor_timeout(
283
+ axis.user_setpoint_demand_readback,
284
+ axis.user_setpoint_readback,
285
+ axis.velocity,
286
+ )
287
+ for axis in axes
288
+ ]
277
289
  )
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
282
-
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()
290
+ return np.max(timeouts)
291
291
 
292
292
 
293
- class UndulatorJawPhase(StandardReadable, Movable):
293
+ class UndulatorJawPhase(SafeUndulatorMover):
294
294
  """
295
295
  A JawPhase movable, this is use for moving the jaw phase which is use to control the
296
296
  linear arbitrary polarisation but only one some of the beamline.
@@ -308,49 +308,22 @@ class UndulatorJawPhase(StandardReadable, Movable):
308
308
  self.jaw_phase = UndulatorPhaseMotor(prefix=prefix, infix=jaw_phase)
309
309
  # Nothing move until this is set to 1 and it will return to 0 when done
310
310
  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
311
 
317
- @AsyncStatus.wrap
318
- async def set(self, value: float) -> None:
319
- LOGGER.info(f"Setting {self.name} to {value}")
320
-
321
- await self.check_id_status()
312
+ super().__init__(self.set_move, prefix, name)
322
313
 
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)
314
+ async def _set_demand_positions(self, value: float) -> None:
315
+ await self.jaw_phase.user_setpoint.set(value=str(value))
329
316
 
330
- async def _cal_timeout(self) -> float:
317
+ async def get_timeout(self) -> float:
331
318
  """
332
319
  Get motor speed, current position and target position to calculate required timeout.
333
320
  """
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(),
321
+ return await estimate_motor_timeout(
322
+ self.jaw_phase.user_setpoint_demand_readback,
323
+ self.jaw_phase.user_setpoint_readback,
324
+ self.jaw_phase.velocity,
338
325
  )
339
326
 
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
327
 
355
328
  class Apple2(StandardReadable, Movable):
356
329
  """
@@ -437,7 +410,7 @@ class Apple2(StandardReadable, Movable):
437
410
  """
438
411
 
439
412
  # Only need to check gap as the phase motors share both fault and gate with gap.
440
- await self.gap().check_id_status()
413
+ await self.gap().raise_if_cannot_move()
441
414
  await asyncio.gather(
442
415
  self.phase().top_outer.user_setpoint.set(value=value.top_outer),
443
416
  self.phase().top_inner.user_setpoint.set(value=value.top_inner),
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)
@@ -62,6 +63,9 @@ class EigerDetector(Device):
62
63
  arming_status = Status()
63
64
  arming_status.set_finished()
64
65
 
66
+ disarming_status = Status()
67
+ disarming_status.set_finished()
68
+
65
69
  def __init__(self, beamline: str = "i03", *args, **kwargs):
66
70
  super().__init__(*args, **kwargs)
67
71
  self.beamline = beamline
@@ -140,6 +144,7 @@ class EigerDetector(Device):
140
144
  def unstage(self) -> bool:
141
145
  assert self.detector_params is not None
142
146
  try:
147
+ self.disarming_status = Status()
143
148
  self.wait_on_arming_if_started()
144
149
  if self.detector_params.trigger_mode == TriggerMode.FREE_RUN:
145
150
  # In free run mode we have to manually stop odin
@@ -157,24 +162,29 @@ class EigerDetector(Device):
157
162
  status_ok = self.odin.check_and_wait_for_odin_state(
158
163
  self.timeouts.general_status_timeout
159
164
  )
160
- self.disable_roi_mode()
165
+ self.disable_roi_mode().wait(self.timeouts.general_status_timeout)
166
+ self.disarming_status.set_finished()
161
167
  return status_ok
162
168
 
163
169
  def stop(self, *args):
164
170
  """Emergency stop the device, mainly used to clean up after error."""
165
171
  LOGGER.info("Eiger stop() called - cleaning up...")
166
- self.wait_on_arming_if_started()
167
- stop_status = self.odin.stop()
168
- self.odin.file_writer.start_timeout.set(1).wait(
169
- self.timeouts.general_status_timeout
170
- )
171
- self.disarm_detector()
172
- stop_status &= self.disable_roi_mode()
173
- stop_status.wait(self.timeouts.general_status_timeout)
174
- # See https://github.com/DiamondLightSource/hyperion/issues/1395
175
- LOGGER.info("Turning off Eiger dev/shm streaming")
176
- self.odin.fan.dev_shm_enable.set(0).wait()
177
- LOGGER.info("Eiger has successfully been stopped")
172
+ if not self.disarming_status.done:
173
+ LOGGER.info("Eiger still disarming, waiting on disarm")
174
+ self.disarming_status.wait(self.timeouts.arming_timeout)
175
+ else:
176
+ self.wait_on_arming_if_started()
177
+ stop_status = self.odin.stop()
178
+ self.odin.file_writer.start_timeout.set(1).wait(
179
+ self.timeouts.general_status_timeout
180
+ )
181
+ self.disarm_detector()
182
+ stop_status &= self.disable_roi_mode()
183
+ stop_status.wait(self.timeouts.general_status_timeout)
184
+ # See https://github.com/DiamondLightSource/hyperion/issues/1395
185
+ LOGGER.info("Turning off Eiger dev/shm streaming")
186
+ self.odin.fan.dev_shm_enable.set(0).wait()
187
+ LOGGER.info("Eiger has successfully been stopped")
178
188
 
179
189
  def disable_roi_mode(self):
180
190
  return self.change_roi_mode(False)
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  from abc import ABC, abstractmethod
2
3
  from typing import Generic, TypeVar
3
4
 
@@ -9,9 +10,7 @@ from ophyd_async.core import (
9
10
  AsyncStatus,
10
11
  Device,
11
12
  Signal,
12
- SignalR,
13
13
  SignalRW,
14
- SoftSignalBackend,
15
14
  StandardReadable,
16
15
  wait_for_value,
17
16
  )
@@ -24,6 +23,7 @@ from ophyd_async.epics.core import (
24
23
  from pydantic import field_validator
25
24
  from pydantic.dataclasses import dataclass
26
25
 
26
+ from dodal.common.signal_utils import create_hardware_backed_soft_signal
27
27
  from dodal.log import LOGGER
28
28
  from dodal.parameters.experiment_parameter_base import AbstractExperimentWithBeamParams
29
29
 
@@ -45,8 +45,19 @@ class GridAxis:
45
45
  # refering to the first position
46
46
  return self.steps_to_motor_position(self.full_steps - 1)
47
47
 
48
- def is_within(self, steps):
49
- return 0 <= steps <= self.full_steps
48
+ def is_within(self, steps: float):
49
+ """
50
+ Determine whether a single axis coordinate is within the grid.
51
+ The coordinate is from a continuous coordinate space based on the
52
+ XRC grid where the origin corresponds to the centre of the first grid box.
53
+
54
+ Args:
55
+ steps: The coordinate to check
56
+
57
+ Returns:
58
+ True if the coordinate falls within the grid.
59
+ """
60
+ return -0.5 <= steps <= self.full_steps - 0.5
50
61
 
51
62
 
52
63
  class GridScanParamsCommon(AbstractExperimentWithBeamParams):
@@ -159,21 +170,6 @@ class MotionProgram(Device):
159
170
  self.program_number = epics_signal_r(float, prefix + "CS1:PROG_NUM")
160
171
 
161
172
 
162
- class ExpectedImages(SignalR[int]):
163
- def __init__(self, parent: "FastGridScanCommon") -> None:
164
- super().__init__(SoftSignalBackend(int))
165
- self.parent = parent
166
-
167
- async def get_value(self, cached: bool | None = None):
168
- assert isinstance(self.parent, FastGridScanCommon)
169
- x = await self.parent.x_steps.get_value()
170
- y = await self.parent.y_steps.get_value()
171
- z = await self.parent.z_steps.get_value()
172
- first_grid = x * y
173
- second_grid = x * z
174
- return first_grid + second_grid
175
-
176
-
177
173
  class FastGridScanCommon(StandardReadable, Flyable, ABC, Generic[ParamType]):
178
174
  """Device for a general fast grid scan
179
175
 
@@ -206,7 +202,9 @@ class FastGridScanCommon(StandardReadable, Flyable, ABC, Generic[ParamType]):
206
202
  self.run_cmd = epics_signal_x(f"{prefix}RUN.PROC")
207
203
  self.status = epics_signal_r(int, f"{prefix}SCAN_STATUS")
208
204
 
209
- self.expected_images = ExpectedImages(parent=self)
205
+ self.expected_images = create_hardware_backed_soft_signal(
206
+ float, self._calculate_expected_images
207
+ )
210
208
 
211
209
  self.motion_program = MotionProgram(smargon_prefix)
212
210
 
@@ -232,6 +230,17 @@ class FastGridScanCommon(StandardReadable, Flyable, ABC, Generic[ParamType]):
232
230
  }
233
231
  super().__init__(name)
234
232
 
233
+ async def _calculate_expected_images(self):
234
+ x, y, z = await asyncio.gather(
235
+ self.x_steps.get_value(),
236
+ self.y_steps.get_value(),
237
+ self.z_steps.get_value(),
238
+ )
239
+ LOGGER.info(f"Reading num of images found {x, y, z} images in each axis")
240
+ first_grid = x * y
241
+ second_grid = x * z
242
+ return first_grid + second_grid
243
+
235
244
  @AsyncStatus.wrap
236
245
  async def kickoff(self):
237
246
  curr_prog = await self.motion_program.program_number.get_value()
@@ -8,9 +8,16 @@ from ophyd_async.core import (
8
8
  )
9
9
  from ophyd_async.epics.core import epics_signal_r, epics_signal_w
10
10
 
11
+ from dodal.log import LOGGER
12
+
11
13
  HUTCH_SAFE_FOR_OPERATIONS = 0 # Hutch is locked and can't be entered
12
14
 
13
15
 
16
+ # Enable to allow testing when the beamline is down, do not change in production!
17
+ TEST_MODE = False
18
+ # will be made more generic in https://github.com/DiamondLightSource/dodal/issues/754
19
+
20
+
14
21
  class ShutterNotSafeToOperateError(Exception):
15
22
  pass
16
23
 
@@ -64,8 +71,8 @@ class HutchShutter(StandardReadable, Movable):
64
71
  """
65
72
 
66
73
  def __init__(self, prefix: str, name: str = "") -> None:
67
- self.control = epics_signal_w(ShutterDemand, prefix + "CON")
68
- self.status = epics_signal_r(ShutterState, prefix + "STA")
74
+ self.control = epics_signal_w(ShutterDemand, f"{prefix}CON")
75
+ self.status = epics_signal_r(ShutterState, f"{prefix}STA")
69
76
 
70
77
  bl_prefix = prefix.split("-")[0]
71
78
  self.interlock = HutchInterlock(bl_prefix)
@@ -75,18 +82,24 @@ class HutchShutter(StandardReadable, Movable):
75
82
  @AsyncStatus.wrap
76
83
  async def set(self, value: ShutterDemand):
77
84
  interlock_state = await self.interlock.shutter_safe_to_operate()
78
- if not interlock_state:
85
+ if not interlock_state and not TEST_MODE:
86
+ # If not in test mode, fail. If in test mode, the optics hutch may be open.
79
87
  raise ShutterNotSafeToOperateError(
80
88
  "The hutch has not been locked, not operating shutter."
81
89
  )
82
- if value == ShutterDemand.OPEN:
83
- await self.control.set(ShutterDemand.RESET, wait=True)
84
- await self.control.set(value, wait=True)
85
- return await wait_for_value(
86
- self.status, match=ShutterState.OPEN, timeout=DEFAULT_TIMEOUT
87
- )
90
+ if not TEST_MODE:
91
+ if value == ShutterDemand.OPEN:
92
+ await self.control.set(ShutterDemand.RESET, wait=True)
93
+ await self.control.set(value, wait=True)
94
+ return await wait_for_value(
95
+ self.status, match=ShutterState.OPEN, timeout=DEFAULT_TIMEOUT
96
+ )
97
+ else:
98
+ await self.control.set(value, wait=True)
99
+ return await wait_for_value(
100
+ self.status, match=ShutterState.CLOSED, timeout=DEFAULT_TIMEOUT
101
+ )
88
102
  else:
89
- await self.control.set(value, wait=True)
90
- return await wait_for_value(
91
- self.status, match=ShutterState.CLOSED, timeout=DEFAULT_TIMEOUT
103
+ LOGGER.warning(
104
+ "Running in test mode, will not operate the experiment shutter."
92
105
  )
@@ -37,32 +37,18 @@ class Transfocator(StandardReadable):
37
37
 
38
38
  super().__init__(name=name)
39
39
 
40
- async def _observe_beamsize_microns(self):
41
- is_set_filters_done = False
42
-
43
- async def set_based_on_prediction(value: float):
44
- if not math.isclose(
45
- self.latest_pred_vertical_num_lenses, value, abs_tol=1e-8
46
- ):
47
- # We can only put an integer number of lenses in the beam but the
48
- # calculation in the IOC returns the theoretical float number of lenses
49
- nonlocal is_set_filters_done
50
- value = round(value)
51
- LOGGER.info(f"Transfocator setting {value} filters")
52
- await self.number_filters_sp.set(value)
53
- await self.start.set(1)
54
- LOGGER.info("Waiting for start_rbv to change to 1")
55
- await wait_for_value(self.start_rbv, 1, self.TIMEOUT)
56
- LOGGER.info("Waiting for start_rbv to change to 0")
57
- await wait_for_value(self.start_rbv, 0, self.TIMEOUT)
58
- self.latest_pred_vertical_num_lenses = value
59
- is_set_filters_done = True
60
-
61
- # The value hasn't changed so assume the device is already set up correctly
62
- async for value in observe_value(self.predicted_vertical_num_lenses):
63
- await set_based_on_prediction(value)
64
- if is_set_filters_done:
65
- break
40
+ async def set_based_on_prediction(self, value: float):
41
+ # We can only put an integer number of lenses in the beam but the
42
+ # calculation in the IOC returns the theoretical float number of lenses
43
+ value = round(value)
44
+ LOGGER.info(f"Transfocator setting {value} filters")
45
+ await self.number_filters_sp.set(value)
46
+ await self.start.set(1)
47
+ LOGGER.info("Waiting for start_rbv to change to 1")
48
+ await wait_for_value(self.start_rbv, 1, self.TIMEOUT)
49
+ LOGGER.info("Waiting for start_rbv to change to 0")
50
+ await wait_for_value(self.start_rbv, 0, self.TIMEOUT)
51
+ self.latest_pred_vertical_num_lenses = value
66
52
 
67
53
  @AsyncStatus.wrap
68
54
  async def set(self, value: float):
@@ -81,10 +67,17 @@ class Transfocator(StandardReadable):
81
67
 
82
68
  if await self.beamsize_set_microns.get_value() != value:
83
69
  # Logic in the IOC calculates predicted_vertical_num_lenses when beam_set_microns changes
84
- await asyncio.gather(
85
- self.beamsize_set_microns.set(value),
86
- self._observe_beamsize_microns(),
70
+
71
+ # Register an observer before setting beamsize_set_microns to ensure we don't miss changes
72
+ predicted_vertical_num_lenses_iterator = observe_value(
73
+ self.predicted_vertical_num_lenses, timeout=self.TIMEOUT
87
74
  )
75
+ # Keep initial prediction before setting to later compare with change after setting
76
+ current_prediction = await anext(predicted_vertical_num_lenses_iterator)
77
+ await self.beamsize_set_microns.set(value)
78
+ accepted_prediction = await anext(predicted_vertical_num_lenses_iterator)
79
+ if not math.isclose(current_prediction, accepted_prediction, abs_tol=1e-8):
80
+ await self.set_based_on_prediction(accepted_prediction)
88
81
 
89
82
  number_filters_rbv, vertical_lens_size_rbv = await asyncio.gather(
90
83
  self.number_filters_sp.get_value(),
@@ -5,8 +5,8 @@ from dodal.devices.current_amplifiers import StruckScaler
5
5
 
6
6
  class RasorScalerCard1(Device):
7
7
  def __init__(self, prefix, name: str = "") -> None:
8
- self.mon = StruckScaler(prefix=prefix, suffix=".16")
9
- self.det = StruckScaler(prefix=prefix, suffix=".17")
10
- self.fluo = StruckScaler(prefix=prefix, suffix=".18")
11
- self.drain = StruckScaler(prefix=prefix, suffix=".19")
8
+ self.mon = StruckScaler(prefix=prefix, suffix=".S17")
9
+ self.det = StruckScaler(prefix=prefix, suffix=".S18")
10
+ self.fluo = StruckScaler(prefix=prefix, suffix=".S19")
11
+ self.drain = StruckScaler(prefix=prefix, suffix=".S20")
12
12
  super().__init__(name)
@@ -1,6 +1,7 @@
1
1
  from ophyd_async.core import PathProvider, StandardDetector
2
2
  from ophyd_async.epics import adcore
3
3
 
4
+ from dodal.common.beamlines.device_helpers import CAM_SUFFIX, HDF5_SUFFIX
4
5
  from dodal.devices.i13_1.merlin_controller import MerlinController
5
6
  from dodal.devices.i13_1.merlin_io import MerlinDriverIO
6
7
 
@@ -13,12 +14,12 @@ class Merlin(StandardDetector):
13
14
  self,
14
15
  prefix: str,
15
16
  path_provider: PathProvider,
16
- drv_suffix="CAM:",
17
- hdf_suffix="HDF:",
17
+ drv_suffix=CAM_SUFFIX,
18
+ fileio_suffix=HDF5_SUFFIX,
18
19
  name: str = "",
19
20
  ):
20
21
  self.drv = MerlinDriverIO(prefix + drv_suffix)
21
- self.hdf = adcore.NDFileHDFIO(prefix + hdf_suffix)
22
+ self.hdf = adcore.NDFileHDFIO(prefix + fileio_suffix)
22
23
 
23
24
  super().__init__(
24
25
  MerlinController(self.drv),
@@ -4,15 +4,15 @@ import logging
4
4
  from ophyd_async.core import (
5
5
  DEFAULT_TIMEOUT,
6
6
  AsyncStatus,
7
- DetectorController,
8
7
  TriggerInfo,
9
8
  )
10
9
  from ophyd_async.epics import adcore
10
+ from ophyd_async.epics.adcore import ADBaseController
11
11
 
12
12
  from dodal.devices.i13_1.merlin_io import MerlinDriverIO, MerlinImageMode
13
13
 
14
14
 
15
- class MerlinController(DetectorController):
15
+ class MerlinController(ADBaseController):
16
16
  def __init__(
17
17
  self,
18
18
  driver: MerlinDriverIO,
@@ -37,11 +37,6 @@ class MerlinController(DetectorController):
37
37
  self.driver.image_mode.set(MerlinImageMode.MULTIPLE),
38
38
  )
39
39
 
40
- async def arm(self):
41
- self._arm_status = await adcore.start_acquiring_driver_and_ensure_status(
42
- self.driver, good_states=self.good_states, timeout=self.frame_timeout
43
- )
44
-
45
40
  async def wait_for_idle(self):
46
41
  if self._arm_status:
47
42
  await self._arm_status