dls-dodal 1.65.0__py3-none-any.whl → 1.67.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 (85) hide show
  1. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/METADATA +3 -4
  2. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/RECORD +82 -66
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/aithre.py +21 -2
  5. dodal/beamlines/i03.py +102 -198
  6. dodal/beamlines/i04.py +40 -4
  7. dodal/beamlines/i05.py +28 -1
  8. dodal/beamlines/i06.py +62 -0
  9. dodal/beamlines/i07.py +20 -0
  10. dodal/beamlines/i09_1.py +32 -3
  11. dodal/beamlines/i09_2.py +57 -2
  12. dodal/beamlines/i10_optics.py +46 -17
  13. dodal/beamlines/i17.py +7 -3
  14. dodal/beamlines/i18.py +3 -3
  15. dodal/beamlines/i19_1.py +26 -14
  16. dodal/beamlines/i19_2.py +49 -38
  17. dodal/beamlines/i21.py +2 -2
  18. dodal/beamlines/i22.py +19 -4
  19. dodal/beamlines/p38.py +3 -3
  20. dodal/beamlines/training_rig.py +0 -16
  21. dodal/cli.py +26 -12
  22. dodal/common/coordination.py +3 -2
  23. dodal/device_manager.py +604 -0
  24. dodal/devices/aithre_lasershaping/goniometer.py +26 -9
  25. dodal/devices/aperturescatterguard.py +3 -2
  26. dodal/devices/areadetector/plugins/mjpg.py +10 -3
  27. dodal/devices/beamsize/__init__.py +0 -0
  28. dodal/devices/beamsize/beamsize.py +6 -0
  29. dodal/devices/cryostream.py +28 -57
  30. dodal/devices/detector/det_resolution.py +4 -2
  31. dodal/devices/eiger.py +26 -18
  32. dodal/devices/fast_grid_scan.py +14 -2
  33. dodal/devices/i03/beamsize.py +35 -0
  34. dodal/devices/i03/constants.py +7 -0
  35. dodal/devices/i03/undulator_dcm.py +2 -2
  36. dodal/devices/i04/beamsize.py +45 -0
  37. dodal/devices/i04/max_pixel.py +38 -0
  38. dodal/devices/i04/murko_results.py +36 -26
  39. dodal/devices/i04/transfocator.py +23 -29
  40. dodal/devices/i07/id.py +38 -0
  41. dodal/devices/i09_1_shared/__init__.py +13 -2
  42. dodal/devices/i09_1_shared/hard_energy.py +112 -0
  43. dodal/devices/i09_1_shared/hard_undulator_functions.py +85 -21
  44. dodal/devices/i09_2_shared/__init__.py +0 -0
  45. dodal/devices/i09_2_shared/i09_apple2.py +86 -0
  46. dodal/devices/i10/i10_apple2.py +39 -331
  47. dodal/devices/i17/i17_apple2.py +37 -22
  48. dodal/devices/i19/access_controlled/attenuator_motor_squad.py +61 -0
  49. dodal/devices/i19/access_controlled/blueapi_device.py +9 -1
  50. dodal/devices/i19/access_controlled/shutter.py +2 -4
  51. dodal/devices/insertion_device/__init__.py +0 -0
  52. dodal/devices/{apple2_undulator.py → insertion_device/apple2_undulator.py} +122 -69
  53. dodal/devices/insertion_device/energy_motor_lookup.py +88 -0
  54. dodal/devices/insertion_device/lookup_table_models.py +287 -0
  55. dodal/devices/ipin.py +20 -2
  56. dodal/devices/motors.py +33 -3
  57. dodal/devices/mx_phase1/beamstop.py +31 -12
  58. dodal/devices/oav/oav_calculations.py +9 -4
  59. dodal/devices/oav/oav_detector.py +65 -7
  60. dodal/devices/oav/oav_parameters.py +3 -1
  61. dodal/devices/oav/oav_to_redis_forwarder.py +18 -15
  62. dodal/devices/oav/pin_image_recognition/__init__.py +5 -1
  63. dodal/devices/oav/pin_image_recognition/utils.py +23 -1
  64. dodal/devices/oav/snapshots/snapshot_with_grid.py +8 -2
  65. dodal/devices/oav/utils.py +16 -6
  66. dodal/devices/robot.py +33 -18
  67. dodal/devices/scintillator.py +36 -14
  68. dodal/devices/smargon.py +2 -3
  69. dodal/devices/thawer.py +7 -45
  70. dodal/devices/undulator.py +152 -68
  71. dodal/plans/__init__.py +1 -1
  72. dodal/plans/configure_arm_trigger_and_disarm_detector.py +2 -4
  73. dodal/plans/load_panda_yaml.py +9 -0
  74. dodal/plans/verify_undulator_gap.py +2 -2
  75. dodal/testing/fixtures/devices/__init__.py +0 -0
  76. dodal/testing/fixtures/devices/apple2.py +78 -0
  77. dodal/utils.py +6 -3
  78. dodal/beamline_specific_utils/i03.py +0 -17
  79. dodal/testing/__init__.py +0 -3
  80. dodal/testing/setup.py +0 -67
  81. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/WHEEL +0 -0
  82. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/entry_points.txt +0 -0
  83. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/licenses/LICENSE +0 -0
  84. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/top_level.txt +0 -0
  85. /dodal/plans/{scanspec.py → spec_path.py} +0 -0
@@ -23,11 +23,13 @@ from ophyd_async.core import (
23
23
  from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_w
24
24
  from ophyd_async.epics.motor import Motor
25
25
 
26
+ from dodal.common.enums import EnabledDisabledUpper
26
27
  from dodal.log import LOGGER
27
28
 
28
29
  T = TypeVar("T")
29
30
 
30
31
  DEFAULT_MOTOR_MIN_TIMEOUT = 10
32
+ MAXIMUM_MOVE_TIME = 550 # There is no useful movements take longer than this.
31
33
 
32
34
 
33
35
  class UndulatorGateStatus(StrictEnum):
@@ -36,16 +38,24 @@ class UndulatorGateStatus(StrictEnum):
36
38
 
37
39
 
38
40
  @dataclass
39
- class Apple2PhasesVal:
41
+ class Apple2LockedPhasesVal:
40
42
  top_outer: str
41
- top_inner: str
42
43
  btm_inner: str
44
+
45
+
46
+ @dataclass
47
+ class Apple2PhasesVal(Apple2LockedPhasesVal):
48
+ top_inner: str
43
49
  btm_outer: str
44
50
 
45
51
 
46
52
  @dataclass
47
- class Apple2Val(Apple2PhasesVal):
53
+ class Apple2Val:
48
54
  gap: str
55
+ phase: Apple2LockedPhasesVal | Apple2PhasesVal
56
+
57
+ def extract_phase_val(self):
58
+ return self.phase
49
59
 
50
60
 
51
61
  class Pol(StrictEnum):
@@ -80,10 +90,7 @@ class SafeUndulatorMover(StandardReadable, Movable[T], Generic[T]):
80
90
  def __init__(self, set_move: SignalW, prefix: str, name: str = ""):
81
91
  # Gate keeper open when move is requested, closed when move is completed
82
92
  self.gate = epics_signal_r(UndulatorGateStatus, prefix + "BLGATE")
83
-
84
- split_pv = prefix.split("-")
85
- fault_pv = f"{split_pv[0]}-{split_pv[1]}-STAT-{split_pv[3]}ANYFAULT"
86
- self.fault = epics_signal_r(float, fault_pv)
93
+ self.status = epics_signal_r(EnabledDisabledUpper, prefix + "IDBLENA")
87
94
  self.set_move = set_move
88
95
  super().__init__(name)
89
96
 
@@ -91,14 +98,14 @@ class SafeUndulatorMover(StandardReadable, Movable[T], Generic[T]):
91
98
  async def set(self, value: T) -> None:
92
99
  LOGGER.info(f"Setting {self.name} to {value}")
93
100
  await self.raise_if_cannot_move()
94
- await self._set_demand_positions(value)
101
+ await self.set_demand_positions(value)
95
102
  timeout = await self.get_timeout()
96
103
  LOGGER.info(f"Moving {self.name} to {value} with timeout = {timeout}")
97
104
  await self.set_move.set(value=1, timeout=timeout)
98
105
  await wait_for_value(self.gate, UndulatorGateStatus.CLOSE, timeout=timeout)
99
106
 
100
107
  @abc.abstractmethod
101
- async def _set_demand_positions(self, value: T) -> None:
108
+ async def set_demand_positions(self, value: T) -> None:
102
109
  """Set the demand positions on the device without actually hitting move."""
103
110
 
104
111
  @abc.abstractmethod
@@ -106,8 +113,8 @@ class SafeUndulatorMover(StandardReadable, Movable[T], Generic[T]):
106
113
  """Get the timeout for the move based on an estimate of how long it will take."""
107
114
 
108
115
  async def raise_if_cannot_move(self) -> None:
109
- if await self.fault.get_value() != 0:
110
- raise RuntimeError(f"{self.name} is in fault state")
116
+ if await self.status.get_value() is not EnabledDisabledUpper.ENABLED:
117
+ raise RuntimeError(f"{self.name} is DISABLED and cannot move.")
111
118
  if await self.gate.get_value() == UndulatorGateStatus.OPEN:
112
119
  raise RuntimeError(f"{self.name} is already in motion.")
113
120
 
@@ -158,7 +165,7 @@ class UndulatorGap(SafeUndulatorMover[float]):
158
165
  self.user_readback = epics_signal_r(float, prefix + "CURRGAPD")
159
166
  super().__init__(self.set_move, prefix, name)
160
167
 
161
- async def _set_demand_positions(self, value: float) -> None:
168
+ async def set_demand_positions(self, value: float) -> None:
162
169
  await self.user_setpoint.set(str(value))
163
170
 
164
171
  async def get_timeout(self) -> float:
@@ -206,49 +213,39 @@ class UndulatorPhaseMotor(StandardReadable):
206
213
  super().__init__(name=name)
207
214
 
208
215
 
209
- class UndulatorPhaseAxes(SafeUndulatorMover[Apple2PhasesVal]):
210
- """
211
- A collection of 4 phase Motor to make up the full id phase motion. We are using the diamond pv convention.
212
- e.g. top_outer == Q1
213
- top_inner == Q2
214
- btm_inner == q3
215
- btm_outer == q4
216
+ Apple2PhaseValType = TypeVar("Apple2PhaseValType", bound=Apple2LockedPhasesVal)
216
217
 
217
- """
218
+
219
+ class UndulatorLockedPhaseAxes(SafeUndulatorMover[Apple2PhaseValType]):
220
+ """Two phase Motor to make up the locked id phase motion."""
218
221
 
219
222
  def __init__(
220
223
  self,
221
224
  prefix: str,
222
225
  top_outer: str,
223
- top_inner: str,
224
226
  btm_inner: str,
225
- btm_outer: str,
226
227
  name: str = "",
227
228
  ):
228
229
  # Gap demand set point and readback
229
230
  with self.add_children_as_readables():
230
231
  self.top_outer = UndulatorPhaseMotor(prefix=prefix, infix=top_outer)
231
- self.top_inner = UndulatorPhaseMotor(prefix=prefix, infix=top_inner)
232
232
  self.btm_inner = UndulatorPhaseMotor(prefix=prefix, infix=btm_inner)
233
- self.btm_outer = UndulatorPhaseMotor(prefix=prefix, infix=btm_outer)
234
233
  # Nothing move until this is set to 1 and it will return to 0 when done.
235
234
  self.set_move = epics_signal_rw(int, f"{prefix}BL{top_outer}" + "MOVE")
236
-
235
+ self.axes = [self.top_outer, self.btm_inner]
237
236
  super().__init__(self.set_move, prefix, name)
238
237
 
239
- async def _set_demand_positions(self, value: Apple2PhasesVal) -> None:
238
+ async def set_demand_positions(self, value: Apple2PhaseValType) -> None:
240
239
  await asyncio.gather(
241
240
  self.top_outer.user_setpoint.set(value=value.top_outer),
242
- self.top_inner.user_setpoint.set(value=value.top_inner),
243
241
  self.btm_inner.user_setpoint.set(value=value.btm_inner),
244
- self.btm_outer.user_setpoint.set(value=value.btm_outer),
245
242
  )
246
243
 
247
244
  async def get_timeout(self) -> float:
248
245
  """
249
246
  Get all four motor speed, current positions and target positions to calculate required timeout.
250
247
  """
251
- axes = [self.top_outer, self.top_inner, self.btm_inner, self.btm_outer]
248
+
252
249
  timeouts = await asyncio.gather(
253
250
  *[
254
251
  estimate_motor_timeout(
@@ -256,7 +253,7 @@ class UndulatorPhaseAxes(SafeUndulatorMover[Apple2PhasesVal]):
256
253
  axis.user_readback,
257
254
  axis.velocity,
258
255
  )
259
- for axis in axes
256
+ for axis in self.axes
260
257
  ]
261
258
  )
262
259
  """A 2.0 multiplier is required to prevent premature motor timeouts in phase
@@ -266,6 +263,42 @@ class UndulatorPhaseAxes(SafeUndulatorMover[Apple2PhasesVal]):
266
263
  return np.max(timeouts) * 2.0
267
264
 
268
265
 
266
+ class UndulatorPhaseAxes(UndulatorLockedPhaseAxes[Apple2PhasesVal]):
267
+ """
268
+ A collection of 4 phase Motor to make up the full id phase motion. We are using the diamond pv convention.
269
+ e.g. top_outer == Q1
270
+ top_inner == Q2
271
+ btm_inner == q3
272
+ btm_outer == q4
273
+
274
+ """
275
+
276
+ def __init__(
277
+ self,
278
+ prefix: str,
279
+ top_outer: str,
280
+ top_inner: str,
281
+ btm_inner: str,
282
+ btm_outer: str,
283
+ name: str = "",
284
+ ):
285
+ # Gap demand set point and readback
286
+ with self.add_children_as_readables():
287
+ self.top_inner = UndulatorPhaseMotor(prefix=prefix, infix=top_inner)
288
+ self.btm_outer = UndulatorPhaseMotor(prefix=prefix, infix=btm_outer)
289
+
290
+ super().__init__(prefix, top_outer=top_outer, btm_inner=btm_inner, name=name)
291
+ self.axes.extend([self.top_inner, self.btm_outer])
292
+
293
+ async def set_demand_positions(self, value: Apple2PhasesVal) -> None:
294
+ await asyncio.gather(
295
+ self.top_outer.user_setpoint.set(value=value.top_outer),
296
+ self.top_inner.user_setpoint.set(value=value.top_inner),
297
+ self.btm_inner.user_setpoint.set(value=value.btm_inner),
298
+ self.btm_outer.user_setpoint.set(value=value.btm_outer),
299
+ )
300
+
301
+
269
302
  class UndulatorJawPhase(SafeUndulatorMover[float]):
270
303
  """
271
304
  A JawPhase movable, this is use for moving the jaw phase which is use to control the
@@ -287,7 +320,7 @@ class UndulatorJawPhase(SafeUndulatorMover[float]):
287
320
 
288
321
  super().__init__(self.set_move, prefix, name)
289
322
 
290
- async def _set_demand_positions(self, value: float) -> None:
323
+ async def set_demand_positions(self, value: float) -> None:
291
324
  await self.jaw_phase.user_setpoint.set(value=str(value))
292
325
 
293
326
  async def get_timeout(self) -> float:
@@ -301,7 +334,10 @@ class UndulatorJawPhase(SafeUndulatorMover[float]):
301
334
  )
302
335
 
303
336
 
304
- class Apple2(StandardReadable, Movable):
337
+ PhaseAxesType = TypeVar("PhaseAxesType", bound=UndulatorLockedPhaseAxes)
338
+
339
+
340
+ class Apple2(StandardReadable, Movable[Apple2Val], Generic[PhaseAxesType]):
305
341
  """
306
342
  Device representing the combined motor controls for an Apple2 undulator.
307
343
 
@@ -313,7 +349,7 @@ class Apple2(StandardReadable, Movable):
313
349
  The undulator phase axes device, consisting of four phase motors.
314
350
  """
315
351
 
316
- def __init__(self, id_gap: UndulatorGap, id_phase: UndulatorPhaseAxes, name=""):
352
+ def __init__(self, id_gap: UndulatorGap, id_phase: PhaseAxesType, name=""):
317
353
  """
318
354
  Parameters
319
355
  ----------
@@ -339,12 +375,12 @@ class Apple2(StandardReadable, Movable):
339
375
 
340
376
  # Only need to check gap as the phase motors share both fault and gate with gap.
341
377
  await self.gap().raise_if_cannot_move()
378
+
342
379
  await asyncio.gather(
343
- self.phase().top_outer.user_setpoint.set(value=id_motor_values.top_outer),
344
- self.phase().top_inner.user_setpoint.set(value=id_motor_values.top_inner),
345
- self.phase().btm_inner.user_setpoint.set(value=id_motor_values.btm_inner),
346
- self.phase().btm_outer.user_setpoint.set(value=id_motor_values.btm_outer),
347
- self.gap().user_setpoint.set(value=id_motor_values.gap),
380
+ self.phase().set_demand_positions(
381
+ value=id_motor_values.extract_phase_val()
382
+ ),
383
+ self.gap().set_demand_positions(value=float(id_motor_values.gap)),
348
384
  )
349
385
  timeout = np.max(
350
386
  await asyncio.gather(self.gap().get_timeout(), self.phase().get_timeout())
@@ -362,12 +398,12 @@ class Apple2(StandardReadable, Movable):
362
398
 
363
399
 
364
400
  class EnergyMotorConvertor(Protocol):
365
- def __call__(self, energy: float, pol: Pol) -> tuple[float, float]:
401
+ def __call__(self, energy: float, pol: Pol) -> float:
366
402
  """Protocol to provide energy to motor position conversion"""
367
403
  ...
368
404
 
369
405
 
370
- Apple2Type = TypeVar("Apple2Type", bound="Apple2")
406
+ Apple2Type = TypeVar("Apple2Type", bound=Apple2)
371
407
 
372
408
 
373
409
  class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
@@ -390,19 +426,18 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
390
426
  Soft signal for the polarisation setpoint.
391
427
  polarisation : derived_signal_rw
392
428
  Hardware-backed signal for polarisation readback and control.
393
- energy_to_motor : EnergyMotorConvertor
394
- Callable that converts energy and polarisation to motor positions.
429
+ gap_energy_to_motor_converter : EnergyMotorConvertor
430
+ Callable that converts energy and polarisation to gap motor positions.
431
+ phase_energy_to_motor_converter : EnergyMotorConvertor
432
+ Callable that converts energy and polarisation to phase motor positions.
395
433
 
396
434
  Abstract Methods
397
435
  ----------------
398
- _set_motors_from_energy(value: float) -> None
399
- Abstract method to set motor positions for a given energy and polarisation.
400
- energy_to_motor : EnergyMotorConvertor
401
- A callable that converts energy and polarisation to motor positions.
402
-
436
+ _get_apple2_value(gap: float, phase: float) -> Apple2Val
437
+ Abstract method to return the Apple2Val used to set the apple2 with.
403
438
  Notes
404
439
  -----
405
- - Subclasses must implement `_set_motors_from_energy` for beamline-specific logic.
440
+ - Subclasses must implement `_get_apple2_value` for beamline-specific logic.
406
441
  - LH3 polarisation is indistinguishable from LH in hardware; special handling is provided.
407
442
  - Supports multiple polarisation modes, including linear horizontal (LH), linear vertical (LV),
408
443
  positive circular (PC), negative circular (NC), and linear arbitrary (LA).
@@ -412,7 +447,9 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
412
447
  def __init__(
413
448
  self,
414
449
  apple2: Apple2Type,
415
- energy_to_motor_converter: EnergyMotorConvertor,
450
+ gap_energy_motor_converter: EnergyMotorConvertor,
451
+ phase_energy_motor_converter: EnergyMotorConvertor,
452
+ units: str = "eV",
416
453
  name: str = "",
417
454
  ) -> None:
418
455
  """
@@ -424,19 +461,20 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
424
461
  name: str
425
462
  Name of the device.
426
463
  """
427
- self.energy_to_motor = energy_to_motor_converter
428
464
  self.apple2 = Reference(apple2)
465
+ self.gap_energy_motor_converter = gap_energy_motor_converter
466
+ self.phase_energy_motor_converter = phase_energy_motor_converter
429
467
 
430
468
  # Store the set energy for readback.
431
469
  self._energy, self._energy_set = soft_signal_r_and_setter(
432
- float, initial_value=None, units="eV"
470
+ float, initial_value=None, units=units
433
471
  )
434
472
  with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
435
473
  self.energy = derived_signal_rw(
436
474
  raw_to_derived=self._read_energy,
437
475
  set_derived=self._set_energy,
438
476
  energy=self._energy,
439
- derived_units="eV",
477
+ derived_units=units,
440
478
  )
441
479
 
442
480
  # Store the polarisation for setpoint. And provide readback for LH3.
@@ -444,30 +482,51 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
444
482
  self.polarisation_setpoint, self._polarisation_setpoint_set = (
445
483
  soft_signal_r_and_setter(Pol)
446
484
  )
485
+ phase = self.apple2().phase()
486
+ # check if undulator phase is unlocked.
487
+ if isinstance(phase, UndulatorPhaseAxes):
488
+ top_inner = phase.top_inner.user_readback
489
+ btm_outer = phase.btm_outer.user_readback
490
+ else:
491
+ # If locked phase axes make the locked phase 0.
492
+ top_inner = btm_outer = soft_signal_rw(float, initial_value=0.0)
493
+
447
494
  with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
448
495
  # Hardware backed read/write for polarisation.
496
+
449
497
  self.polarisation = derived_signal_rw(
450
498
  raw_to_derived=self._read_pol,
451
499
  set_derived=self._set_pol,
452
500
  pol=self.polarisation_setpoint,
453
- top_outer=self.apple2().phase().top_outer.user_readback,
454
- top_inner=self.apple2().phase().top_inner.user_readback,
455
- btm_inner=self.apple2().phase().btm_inner.user_readback,
456
- btm_outer=self.apple2().phase().btm_outer.user_readback,
501
+ top_outer=phase.top_outer.user_readback,
502
+ top_inner=top_inner,
503
+ btm_inner=phase.btm_inner.user_readback,
504
+ btm_outer=btm_outer,
457
505
  gap=self.apple2().gap().user_readback,
458
506
  )
459
507
  super().__init__(name)
460
508
 
461
509
  @abc.abstractmethod
462
- async def _set_motors_from_energy(self, value: float) -> None:
510
+ def _get_apple2_value(self, gap: float, phase: float, pol: Pol) -> Apple2Val:
463
511
  """
464
512
  This method should be implemented by the beamline specific ID class as the
465
513
  motor positions will be different for each beamline depending on the
466
- undulator design and the lookup table used.
514
+ undulator design.
467
515
  """
468
516
 
517
+ async def _set_motors_from_energy_and_polarisation(
518
+ self, energy: float, pol: Pol
519
+ ) -> None:
520
+ """Set the undulator motors for a given energy and polarisation."""
521
+ gap = self.gap_energy_motor_converter(energy=energy, pol=pol)
522
+ phase = self.phase_energy_motor_converter(energy=energy, pol=pol)
523
+ apple2_val = self._get_apple2_value(gap, phase, pol)
524
+ LOGGER.info(f"Setting polarisation to {pol}, with values: {apple2_val}")
525
+ await self.apple2().set(id_motor_values=apple2_val)
526
+
469
527
  async def _set_energy(self, energy: float) -> None:
470
- await self._set_motors_from_energy(energy)
528
+ pol = await self._check_and_get_pol_setpoint()
529
+ await self._set_motors_from_energy_and_polarisation(energy, pol)
471
530
  self._energy_set(energy)
472
531
 
473
532
  def _read_energy(self, energy: float) -> float:
@@ -479,7 +538,6 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
479
538
  Check the polarisation setpoint and if it is NONE try to read it from
480
539
  hardware.
481
540
  """
482
-
483
541
  pol = await self.polarisation_setpoint.get_value()
484
542
 
485
543
  if pol == Pol.NONE:
@@ -501,7 +559,7 @@ class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
501
559
  ) -> None:
502
560
  # This changes the pol setpoint and then changes polarisation via set energy.
503
561
  self._polarisation_setpoint_set(value)
504
- await self.energy.set(await self.energy.get_value())
562
+ await self.energy.set(await self.energy.get_value(), timeout=MAXIMUM_MOVE_TIME)
505
563
 
506
564
  def _read_pol(
507
565
  self,
@@ -672,16 +730,11 @@ class InsertionDeviceEnergy(InsertionDeviceEnergyBase):
672
730
  self.energy = Reference(id_controller.energy)
673
731
  super().__init__(name=name)
674
732
 
675
- self.add_readables(
676
- [
677
- self.energy(),
678
- ],
679
- StandardReadableFormat.HINTED_SIGNAL,
680
- )
733
+ self.add_readables([self.energy()], StandardReadableFormat.HINTED_SIGNAL)
681
734
 
682
735
  @AsyncStatus.wrap
683
736
  async def set(self, energy: float) -> None:
684
- await self.energy().set(energy)
737
+ await self.energy().set(energy, timeout=MAXIMUM_MOVE_TIME)
685
738
 
686
739
 
687
740
  class InsertionDevicePolarisation(StandardReadable, Locatable[Pol]):
@@ -696,7 +749,7 @@ class InsertionDevicePolarisation(StandardReadable, Locatable[Pol]):
696
749
 
697
750
  @AsyncStatus.wrap
698
751
  async def set(self, pol: Pol) -> None:
699
- await self.polarisation().set(pol)
752
+ await self.polarisation().set(pol, timeout=MAXIMUM_MOVE_TIME)
700
753
 
701
754
  async def locate(self) -> Location[Pol]:
702
755
  """Return the current polarisation"""
@@ -0,0 +1,88 @@
1
+ from pathlib import Path
2
+
3
+ from daq_config_server.client import ConfigServer
4
+
5
+ from dodal.devices.insertion_device.apple2_undulator import Pol
6
+ from dodal.devices.insertion_device.lookup_table_models import (
7
+ LookupTable,
8
+ LookupTableColumnConfig,
9
+ convert_csv_to_lookup,
10
+ )
11
+
12
+
13
+ class EnergyMotorLookup:
14
+ """
15
+ Handles a lookup table for Apple2 ID, converting energy/polarisation to a motor
16
+ position.
17
+
18
+ After update_lookup_table() has populated the lookup table, `find_value_in_lookup_table()`
19
+ can be used to compute gap / phase for a requested energy / polarisation pair.
20
+ """
21
+
22
+ def __init__(self, lut: LookupTable | None = None):
23
+ if lut is None:
24
+ lut = LookupTable()
25
+ self.lut = lut
26
+
27
+ def update_lookup_table(self) -> None:
28
+ """Do nothing by default. Sub classes may override this method to provide logic
29
+ on what updating lookup table does."""
30
+ pass
31
+
32
+ def find_value_in_lookup_table(self, energy: float, pol: Pol) -> float:
33
+ """
34
+ Convert energy and polarisation to a value from the lookup table.
35
+
36
+ Parameters:
37
+ -----------
38
+ energy : float
39
+ Desired energy.
40
+ pol : Pol
41
+ Polarisation mode.
42
+
43
+ Returns:
44
+ ----------
45
+ float
46
+ gap / phase motor position from the lookup table.
47
+ """
48
+ # if lut is empty, force an update to pull updated lut incase subclasses have
49
+ # implemented it.
50
+ if not self.lut.root:
51
+ self.update_lookup_table()
52
+ poly = self.lut.get_poly(energy=energy, pol=pol)
53
+ return poly(energy)
54
+
55
+
56
+ class ConfigServerEnergyMotorLookup(EnergyMotorLookup):
57
+ """Fetches and parses lookup table (csv) from a config server, supports dynamic
58
+ updates, and validates input."""
59
+
60
+ def __init__(
61
+ self,
62
+ config_client: ConfigServer,
63
+ lut_config: LookupTableColumnConfig,
64
+ path: Path,
65
+ ):
66
+ """
67
+ Parameters:
68
+ -----------
69
+ config_client:
70
+ The config server client to fetch the look up table data.
71
+ lut_config:
72
+ Configuration that defines how to process file contents into a LookupTable
73
+ path:
74
+ File path to the lookup table.
75
+ """
76
+ self.path = path
77
+ self.config_client = config_client
78
+ self.lut_config = lut_config
79
+ super().__init__()
80
+
81
+ def read_lut(self) -> LookupTable:
82
+ file_contents = self.config_client.get_file_contents(
83
+ self.path, reset_cached_result=True
84
+ )
85
+ return convert_csv_to_lookup(file_contents, lut_config=self.lut_config)
86
+
87
+ def update_lookup_table(self) -> None:
88
+ self.lut = self.read_lut()