dls-dodal 1.67.0__py3-none-any.whl → 1.69.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 (86) hide show
  1. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/METADATA +2 -32
  2. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/RECORD +79 -71
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/adsim.py +30 -23
  5. dodal/beamlines/b07.py +1 -1
  6. dodal/beamlines/b07_1.py +1 -1
  7. dodal/beamlines/i02_1.py +14 -42
  8. dodal/beamlines/i02_2.py +5 -11
  9. dodal/beamlines/i03.py +4 -1
  10. dodal/beamlines/i03_supervisor.py +19 -0
  11. dodal/beamlines/i04.py +74 -179
  12. dodal/beamlines/i05.py +9 -1
  13. dodal/beamlines/i06.py +1 -1
  14. dodal/beamlines/i06_1.py +24 -0
  15. dodal/beamlines/i09.py +53 -9
  16. dodal/beamlines/i09_1.py +9 -1
  17. dodal/beamlines/i09_2.py +7 -6
  18. dodal/beamlines/i10_optics.py +1 -1
  19. dodal/beamlines/i16.py +34 -0
  20. dodal/beamlines/i17.py +1 -1
  21. dodal/beamlines/i20_1.py +14 -0
  22. dodal/beamlines/i21.py +71 -4
  23. dodal/beamlines/i23.py +19 -25
  24. dodal/beamlines/i24.py +55 -105
  25. dodal/beamlines/p60.py +12 -2
  26. dodal/common/__init__.py +2 -1
  27. dodal/common/maths.py +80 -0
  28. dodal/devices/eiger.py +44 -23
  29. dodal/devices/electron_analyser/__init__.py +0 -33
  30. dodal/devices/electron_analyser/base/__init__.py +58 -0
  31. dodal/devices/electron_analyser/base/base_controller.py +84 -0
  32. dodal/devices/electron_analyser/base/base_detector.py +214 -0
  33. dodal/devices/electron_analyser/{abstract → base}/base_driver_io.py +23 -42
  34. dodal/devices/electron_analyser/{enums.py → base/base_enums.py} +0 -5
  35. dodal/devices/electron_analyser/{abstract → base}/base_region.py +48 -11
  36. dodal/devices/electron_analyser/{util.py → base/base_util.py} +1 -1
  37. dodal/devices/electron_analyser/{energy_sources.py → base/energy_sources.py} +27 -26
  38. dodal/devices/electron_analyser/specs/__init__.py +4 -4
  39. dodal/devices/electron_analyser/specs/specs_detector.py +47 -0
  40. dodal/devices/electron_analyser/specs/{driver_io.py → specs_driver_io.py} +23 -26
  41. dodal/devices/electron_analyser/specs/{region.py → specs_region.py} +4 -3
  42. dodal/devices/electron_analyser/vgscienta/__init__.py +4 -4
  43. dodal/devices/electron_analyser/vgscienta/vgscienta_detector.py +53 -0
  44. dodal/devices/electron_analyser/vgscienta/{driver_io.py → vgscienta_driver_io.py} +25 -31
  45. dodal/devices/electron_analyser/vgscienta/{region.py → vgscienta_region.py} +6 -6
  46. dodal/devices/fast_shutter.py +108 -25
  47. dodal/devices/i04/beam_centre.py +84 -0
  48. dodal/devices/i04/max_pixel.py +4 -17
  49. dodal/devices/i04/murko_results.py +18 -3
  50. dodal/devices/i09_2_shared/i09_apple2.py +0 -72
  51. dodal/devices/i10/i10_apple2.py +7 -7
  52. dodal/devices/i17/i17_apple2.py +6 -6
  53. dodal/devices/i21/__init__.py +3 -1
  54. dodal/devices/i24/commissioning_jungfrau.py +9 -10
  55. dodal/devices/insertion_device/__init__.py +62 -0
  56. dodal/devices/insertion_device/apple2_controller.py +380 -0
  57. dodal/devices/insertion_device/apple2_undulator.py +152 -481
  58. dodal/devices/insertion_device/energy.py +88 -0
  59. dodal/devices/insertion_device/energy_motor_lookup.py +1 -1
  60. dodal/devices/insertion_device/enum.py +17 -0
  61. dodal/devices/insertion_device/lookup_table_models.py +66 -36
  62. dodal/devices/insertion_device/polarisation.py +36 -0
  63. dodal/devices/oav/oav_detector.py +66 -1
  64. dodal/devices/oav/utils.py +17 -0
  65. dodal/devices/robot.py +35 -18
  66. dodal/devices/selectable_source.py +38 -0
  67. dodal/devices/zebra/zebra.py +15 -0
  68. dodal/devices/zebra/zebra_constants_mapping.py +1 -0
  69. dodal/plans/configure_arm_trigger_and_disarm_detector.py +0 -1
  70. dodal/testing/__init__.py +0 -0
  71. dodal/testing/electron_analyser/device_factory.py +4 -4
  72. dodal/testing/fixtures/devices/apple2.py +1 -1
  73. dodal/testing/fixtures/run_engine.py +4 -0
  74. dodal/devices/electron_analyser/abstract/__init__.py +0 -25
  75. dodal/devices/electron_analyser/abstract/base_detector.py +0 -63
  76. dodal/devices/electron_analyser/abstract/types.py +0 -12
  77. dodal/devices/electron_analyser/detector.py +0 -143
  78. dodal/devices/electron_analyser/specs/detector.py +0 -34
  79. dodal/devices/electron_analyser/types.py +0 -57
  80. dodal/devices/electron_analyser/vgscienta/detector.py +0 -48
  81. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/WHEEL +0 -0
  82. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/entry_points.txt +0 -0
  83. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/licenses/LICENSE +0 -0
  84. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/top_level.txt +0 -0
  85. /dodal/devices/electron_analyser/specs/{enums.py → specs_enums.py} +0 -0
  86. /dodal/devices/electron_analyser/vgscienta/{enums.py → vgscienta_enums.py} +0 -0
@@ -1,78 +1,58 @@
1
1
  import abc
2
2
  import asyncio
3
3
  from dataclasses import dataclass
4
- from math import isclose
5
- from typing import Generic, Protocol, TypeVar
4
+ from typing import Generic, TypeVar
6
5
 
7
6
  import numpy as np
8
- from bluesky.protocols import Locatable, Location, Movable
7
+ from bluesky.protocols import Movable
9
8
  from ophyd_async.core import (
9
+ DEFAULT_TIMEOUT,
10
10
  AsyncStatus,
11
+ Device,
12
+ FlyMotorInfo,
11
13
  Reference,
12
14
  SignalR,
13
- SignalRW,
14
15
  SignalW,
15
16
  StandardReadable,
16
17
  StandardReadableFormat,
17
- StrictEnum,
18
- derived_signal_rw,
19
- soft_signal_r_and_setter,
20
- soft_signal_rw,
18
+ WatchableAsyncStatus,
19
+ WatcherUpdate,
20
+ observe_value,
21
21
  wait_for_value,
22
22
  )
23
- from ophyd_async.epics.core import epics_signal_r, epics_signal_rw, epics_signal_w
23
+ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
24
24
  from ophyd_async.epics.motor import Motor
25
25
 
26
26
  from dodal.common.enums import EnabledDisabledUpper
27
+ from dodal.devices.insertion_device.enum import UndulatorGateStatus
27
28
  from dodal.log import LOGGER
28
29
 
29
30
  T = TypeVar("T")
30
31
 
31
32
  DEFAULT_MOTOR_MIN_TIMEOUT = 10
32
- MAXIMUM_MOVE_TIME = 550 # There is no useful movements take longer than this.
33
-
34
-
35
- class UndulatorGateStatus(StrictEnum):
36
- OPEN = "Open"
37
- CLOSE = "Closed"
38
33
 
39
34
 
40
35
  @dataclass
41
36
  class Apple2LockedPhasesVal:
42
- top_outer: str
43
- btm_inner: str
37
+ top_outer: float
38
+ btm_inner: float
44
39
 
45
40
 
46
41
  @dataclass
47
42
  class Apple2PhasesVal(Apple2LockedPhasesVal):
48
- top_inner: str
49
- btm_outer: str
43
+ top_inner: float
44
+ btm_outer: float
50
45
 
51
46
 
52
47
  @dataclass
53
48
  class Apple2Val:
54
- gap: str
49
+ gap: float
55
50
  phase: Apple2LockedPhasesVal | Apple2PhasesVal
56
51
 
57
52
  def extract_phase_val(self):
58
53
  return self.phase
59
54
 
60
55
 
61
- class Pol(StrictEnum):
62
- NONE = "None"
63
- LH = "lh"
64
- LV = "lv"
65
- PC = "pc"
66
- NC = "nc"
67
- LA = "la"
68
- LH3 = "lh3"
69
-
70
-
71
- ROW_PHASE_MOTOR_TOLERANCE = 0.004
72
- MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
73
- MAXIMUM_GAP_MOTOR_POSITION = 100
74
-
75
-
76
56
  async def estimate_motor_timeout(
77
57
  setpoint: SignalR, curr_pos: SignalR, velocity: SignalR
78
58
  ):
@@ -82,7 +62,35 @@ async def estimate_motor_timeout(
82
62
  return abs((target_pos - cur_pos) * 2.0 / vel) + DEFAULT_MOTOR_MIN_TIMEOUT
83
63
 
84
64
 
85
- class SafeUndulatorMover(StandardReadable, Movable[T], Generic[T]):
65
+ class UndulatorBase(abc.ABC, Device, Generic[T]):
66
+ """Abstract base class for Apple2 undulator devices.
67
+ This class provides common functionality for undulator devices, including
68
+ gate and status signal management, safety checks before motion, and abstract
69
+ methods for setting demand positions and estimating move timeouts.
70
+ """
71
+
72
+ def __init__(self, name: str = ""):
73
+ # Gate keeper open when move is requested, closed when move is completed
74
+ self.gate: SignalR[UndulatorGateStatus]
75
+ self.status: SignalR[EnabledDisabledUpper]
76
+ super().__init__(name=name)
77
+
78
+ @abc.abstractmethod
79
+ async def set_demand_positions(self, value: T) -> None:
80
+ """Set the demand positions on the device without actually hitting move."""
81
+
82
+ @abc.abstractmethod
83
+ async def get_timeout(self) -> float:
84
+ """Get the timeout for the move based on an estimate of how long it will take."""
85
+
86
+ async def raise_if_cannot_move(self) -> None:
87
+ if await self.status.get_value() is EnabledDisabledUpper.DISABLED:
88
+ raise RuntimeError(f"{self.name} is DISABLED and cannot move.")
89
+ if await self.gate.get_value() is UndulatorGateStatus.OPEN:
90
+ raise RuntimeError(f"{self.name} is already in motion.")
91
+
92
+
93
+ class SafeUndulatorMover(StandardReadable, UndulatorBase, Generic[T]):
86
94
  """A device that will check it's safe to move the undulator before moving it and
87
95
  wait for the undulator to be safe again before calling the move complete.
88
96
  """
@@ -104,27 +112,65 @@ class SafeUndulatorMover(StandardReadable, Movable[T], Generic[T]):
104
112
  await self.set_move.set(value=1, timeout=timeout)
105
113
  await wait_for_value(self.gate, UndulatorGateStatus.CLOSE, timeout=timeout)
106
114
 
107
- @abc.abstractmethod
108
- async def set_demand_positions(self, value: T) -> None:
109
- """Set the demand positions on the device without actually hitting move."""
110
115
 
111
- @abc.abstractmethod
112
- async def get_timeout(self) -> float:
113
- """Get the timeout for the move based on an estimate of how long it will take."""
116
+ class UnstoppableMotor(Motor):
117
+ """A motor that does not support stop."""
114
118
 
115
- async def raise_if_cannot_move(self) -> None:
116
- if await self.status.get_value() is not EnabledDisabledUpper.ENABLED:
117
- raise RuntimeError(f"{self.name} is DISABLED and cannot move.")
118
- if await self.gate.get_value() == UndulatorGateStatus.OPEN:
119
- raise RuntimeError(f"{self.name} is already in motion.")
119
+ def __init__(self, prefix: str, name: str = ""):
120
+ super().__init__(prefix=prefix, name=name)
121
+ del self.motor_stop # Remove motor_stop from the public interface
120
122
 
123
+ async def stop(self, success=False):
124
+ LOGGER.warning(f"Stopping {self.name} is not supported.")
121
125
 
122
- class UndulatorGap(SafeUndulatorMover[float]):
123
- """A device with a collection of epics signals to set Apple 2 undulator gap motion.
124
- Only PV used by beamline are added the full list is here:
125
- /dls_sw/work/R3.14.12.7/support/insertionDevice/db/IDGapVelocityControl.template
126
- /dls_sw/work/R3.14.12.7/support/insertionDevice/db/IDPhaseSoftMotor.template
127
- """
126
+
127
+ class GapSafeMotorNoStop(UnstoppableMotor, UndulatorBase[float]):
128
+ """Update gap safe motor that checks it's safe to move before moving."""
129
+
130
+ def __init__(self, set_move: SignalW[int], prefix: str, name: str = ""):
131
+ # Gate keeper open when move is requested, closed when move is completed
132
+ self.gate = epics_signal_r(UndulatorGateStatus, prefix + "BLGATE")
133
+ self.status = epics_signal_r(EnabledDisabledUpper, prefix + "IDBLENA")
134
+ self.set_move = set_move
135
+ super().__init__(prefix=prefix + "BLGAPMTR", name=name)
136
+
137
+ @WatchableAsyncStatus.wrap
138
+ async def set(self, new_position: float, timeout=DEFAULT_TIMEOUT):
139
+ (
140
+ old_position,
141
+ units,
142
+ precision,
143
+ ) = await asyncio.gather(
144
+ self.user_setpoint.get_value(),
145
+ self.motor_egu.get_value(),
146
+ self.precision.get_value(),
147
+ )
148
+ LOGGER.info(f"Setting {self.name} to {new_position}")
149
+ await self.raise_if_cannot_move()
150
+ await self.set_demand_positions(new_position)
151
+ timeout = await self.get_timeout()
152
+ LOGGER.info(f"Moving {self.name} to {new_position} with timeout = {timeout}")
153
+
154
+ await self.set_move.set(value=1, wait=True, timeout=timeout)
155
+ move_status = AsyncStatus(
156
+ wait_for_value(self.gate, UndulatorGateStatus.CLOSE, timeout=timeout)
157
+ )
158
+
159
+ async for current_position in observe_value(
160
+ self.user_readback, done_status=move_status
161
+ ):
162
+ yield WatcherUpdate(
163
+ current=current_position,
164
+ initial=old_position,
165
+ target=new_position,
166
+ name=self.name,
167
+ unit=units,
168
+ precision=precision,
169
+ )
170
+
171
+
172
+ class UndulatorGap(GapSafeMotorNoStop):
173
+ """Apple 2 undulator gap motor device. With PV corrections."""
128
174
 
129
175
  def __init__(self, prefix: str, name: str = ""):
130
176
  """
@@ -137,80 +183,67 @@ class UndulatorGap(SafeUndulatorMover[float]):
137
183
  Name of the Id device
138
184
 
139
185
  """
140
-
141
- # Gap demand set point and readback
142
- self.user_setpoint = epics_signal_rw(
143
- str, prefix + "GAPSET.B", prefix + "BLGSET"
144
- )
145
- # Nothing move until this is set to 1 and it will return to 0 when done
146
186
  self.set_move = epics_signal_rw(int, prefix + "BLGSETP")
187
+ # Nothing move until this is set to 1 and it will return to 0 when done.
188
+ super().__init__(self.set_move, prefix, name)
147
189
 
148
- # These are gap velocity limit.
149
190
  self.max_velocity = epics_signal_r(float, prefix + "BLGSETVEL.HOPR")
150
191
  self.min_velocity = epics_signal_r(float, prefix + "BLGSETVEL.LOPR")
151
- # These are gap limit.
152
- self.high_limit_travel = epics_signal_r(float, prefix + "BLGAPMTR.HLM")
153
- self.low_limit_travel = epics_signal_r(float, prefix + "BLGAPMTR.LLM")
154
-
155
- # This is calculated acceleration from speed
156
- self.acceleration_time = epics_signal_r(float, prefix + "IDGSETACC")
157
-
158
- with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
159
- # Unit
160
- self.motor_egu = epics_signal_r(str, prefix + "BLGAPMTR.EGU")
161
- # Gap velocity
162
- self.velocity = epics_signal_rw(float, prefix + "BLGSETVEL")
163
- with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
164
- # Gap readback value
165
- self.user_readback = epics_signal_r(float, prefix + "CURRGAPD")
166
- super().__init__(self.set_move, prefix, name)
192
+ self.user_setpoint = epics_signal_rw(str, prefix + "BLGSET")
193
+ """ Clear the motor config_signal as we need new PV for velocity."""
194
+ self._describe_config_funcs = ()
195
+ self._read_config_funcs = ()
196
+ self.velocity = epics_signal_rw(float, prefix + "BLGSETVEL")
197
+ self.add_readables(
198
+ [self.velocity, self.motor_egu, self.offset],
199
+ format=StandardReadableFormat.CONFIG_SIGNAL,
200
+ )
167
201
 
168
- async def set_demand_positions(self, value: float) -> None:
169
- await self.user_setpoint.set(str(value))
202
+ @AsyncStatus.wrap
203
+ async def prepare(self, value: FlyMotorInfo) -> None:
204
+ """
205
+ Prepare for a fly scan by moving to the run-up position at max velocity.
206
+ Stores fly info for later use in kickoff.
207
+ """
208
+ max_velocity, min_velocity, egu = await asyncio.gather(
209
+ self.max_velocity.get_value(),
210
+ self.min_velocity.get_value(),
211
+ self.motor_egu.get_value(),
212
+ )
213
+ velocity = abs(value.velocity)
214
+ if not (min_velocity <= velocity <= max_velocity):
215
+ raise ValueError(
216
+ f"Requested velocity {velocity} {egu}/s is out of bounds: "
217
+ f"must be between {min_velocity} and {max_velocity} {egu}/s."
218
+ )
219
+ await super().prepare(value)
170
220
 
171
221
  async def get_timeout(self) -> float:
172
222
  return await estimate_motor_timeout(
173
223
  self.user_setpoint, self.user_readback, self.velocity
174
224
  )
175
225
 
226
+ async def set_demand_positions(self, value: float) -> None:
227
+ await self.user_setpoint.set(str(value))
228
+
176
229
 
177
- class UndulatorPhaseMotor(StandardReadable):
178
- """A collection of epics signals for ID phase motion.
179
- Only PV used by beamline are added the full list is here:
180
- /dls_sw/work/R3.14.12.7/support/insertionDevice/db/IDPhaseSoftMotor.template
181
- """
230
+ class UndulatorPhaseMotor(UnstoppableMotor):
231
+ """Phase motor that will not stop."""
182
232
 
183
- def __init__(self, prefix: str, infix: str, name: str = ""):
233
+ def __init__(self, prefix: str, name: str = ""):
184
234
  """
185
235
  Parameters
186
236
  ----------
187
237
 
188
238
  prefix : str
189
239
  The setting prefix PV.
190
- infix: str
191
- Collection of pv that are different between beamlines
192
240
  name : str
193
241
  Name of the Id phase device
194
242
  """
195
- full_pv = f"{prefix}BL{infix}"
196
- self.user_setpoint = epics_signal_w(str, full_pv + "SET")
197
- self.user_setpoint_readback = epics_signal_r(float, full_pv + "DMD")
198
- full_pv = full_pv + "MTR"
199
- with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
200
- self.user_readback = epics_signal_r(float, full_pv + ".RBV")
201
-
202
- with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
203
- self.motor_egu = epics_signal_r(str, full_pv + ".EGU")
204
- self.velocity = epics_signal_rw(float, full_pv + ".VELO")
205
-
206
- self.max_velocity = epics_signal_r(float, full_pv + ".VMAX")
207
- self.acceleration_time = epics_signal_rw(float, full_pv + ".ACCL")
208
- self.precision = epics_signal_r(int, full_pv + ".PREC")
209
- self.deadband = epics_signal_r(float, full_pv + ".RDBD")
210
- self.motor_done_move = epics_signal_r(int, full_pv + ".DMOV")
211
- self.low_limit_travel = epics_signal_rw(float, full_pv + ".LLM")
212
- self.high_limit_travel = epics_signal_rw(float, full_pv + ".HLM")
213
- super().__init__(name=name)
243
+ motor_pv = f"{prefix}MTR"
244
+ super().__init__(prefix=motor_pv, name=name)
245
+ self.user_setpoint = epics_signal_rw(str, prefix + "SET")
246
+ self.user_setpoint_readback = epics_signal_r(float, prefix + "DMD")
214
247
 
215
248
 
216
249
  Apple2PhaseValType = TypeVar("Apple2PhaseValType", bound=Apple2LockedPhasesVal)
@@ -228,8 +261,8 @@ class UndulatorLockedPhaseAxes(SafeUndulatorMover[Apple2PhaseValType]):
228
261
  ):
229
262
  # Gap demand set point and readback
230
263
  with self.add_children_as_readables():
231
- self.top_outer = UndulatorPhaseMotor(prefix=prefix, infix=top_outer)
232
- self.btm_inner = UndulatorPhaseMotor(prefix=prefix, infix=btm_inner)
264
+ self.top_outer = UndulatorPhaseMotor(prefix=f"{prefix}BL{top_outer}")
265
+ self.btm_inner = UndulatorPhaseMotor(prefix=f"{prefix}BL{btm_inner}")
233
266
  # Nothing move until this is set to 1 and it will return to 0 when done.
234
267
  self.set_move = epics_signal_rw(int, f"{prefix}BL{top_outer}" + "MOVE")
235
268
  self.axes = [self.top_outer, self.btm_inner]
@@ -237,13 +270,13 @@ class UndulatorLockedPhaseAxes(SafeUndulatorMover[Apple2PhaseValType]):
237
270
 
238
271
  async def set_demand_positions(self, value: Apple2PhaseValType) -> None:
239
272
  await asyncio.gather(
240
- self.top_outer.user_setpoint.set(value=value.top_outer),
241
- self.btm_inner.user_setpoint.set(value=value.btm_inner),
273
+ self.top_outer.user_setpoint.set(value=str(value.top_outer)),
274
+ self.btm_inner.user_setpoint.set(value=str(value.btm_inner)),
242
275
  )
243
276
 
244
277
  async def get_timeout(self) -> float:
245
278
  """
246
- Get all four motor speed, current positions and target positions to calculate required timeout.
279
+ Get all motor speed, current positions and target positions to calculate required timeout.
247
280
  """
248
281
 
249
282
  timeouts = await asyncio.gather(
@@ -284,18 +317,18 @@ class UndulatorPhaseAxes(UndulatorLockedPhaseAxes[Apple2PhasesVal]):
284
317
  ):
285
318
  # Gap demand set point and readback
286
319
  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)
320
+ self.top_inner = UndulatorPhaseMotor(prefix=f"{prefix}BL{top_inner}")
321
+ self.btm_outer = UndulatorPhaseMotor(prefix=f"{prefix}BL{btm_outer}")
289
322
 
290
323
  super().__init__(prefix, top_outer=top_outer, btm_inner=btm_inner, name=name)
291
324
  self.axes.extend([self.top_inner, self.btm_outer])
292
325
 
293
326
  async def set_demand_positions(self, value: Apple2PhasesVal) -> None:
294
327
  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),
328
+ self.top_outer.user_setpoint.set(value=str(value.top_outer)),
329
+ self.top_inner.user_setpoint.set(value=str(value.top_inner)),
330
+ self.btm_inner.user_setpoint.set(value=str(value.btm_inner)),
331
+ self.btm_outer.user_setpoint.set(value=str(value.btm_outer)),
299
332
  )
300
333
 
301
334
 
@@ -314,7 +347,7 @@ class UndulatorJawPhase(SafeUndulatorMover[float]):
314
347
  ):
315
348
  # Gap demand set point and readback
316
349
  with self.add_children_as_readables():
317
- self.jaw_phase = UndulatorPhaseMotor(prefix=prefix, infix=jaw_phase)
350
+ self.jaw_phase = UndulatorPhaseMotor(prefix=f"{prefix}BL{jaw_phase}")
318
351
  # Nothing move until this is set to 1 and it will return to 0 when done
319
352
  self.set_move = epics_signal_rw(int, f"{prefix}BL{move_pv}" + "MOVE")
320
353
 
@@ -373,7 +406,7 @@ class Apple2(StandardReadable, Movable[Apple2Val], Generic[PhaseAxesType]):
373
406
  all at the same time.
374
407
  """
375
408
 
376
- # Only need to check gap as the phase motors share both fault and gate with gap.
409
+ # Only need to check gap as the phase motors share both status and gate with gap.
377
410
  await self.gap().raise_if_cannot_move()
378
411
 
379
412
  await asyncio.gather(
@@ -386,7 +419,7 @@ class Apple2(StandardReadable, Movable[Apple2Val], Generic[PhaseAxesType]):
386
419
  await asyncio.gather(self.gap().get_timeout(), self.phase().get_timeout())
387
420
  )
388
421
  LOGGER.info(
389
- f"Moving f{self.name} apple2 motors to {id_motor_values}, timeout = {timeout}"
422
+ f"Moving {self.name} apple2 motors to {id_motor_values}, timeout = {timeout}"
390
423
  )
391
424
  await asyncio.gather(
392
425
  self.gap().set_move.set(value=1, wait=False, timeout=timeout),
@@ -395,365 +428,3 @@ class Apple2(StandardReadable, Movable[Apple2Val], Generic[PhaseAxesType]):
395
428
  await wait_for_value(
396
429
  self.gap().gate, UndulatorGateStatus.CLOSE, timeout=timeout
397
430
  )
398
-
399
-
400
- class EnergyMotorConvertor(Protocol):
401
- def __call__(self, energy: float, pol: Pol) -> float:
402
- """Protocol to provide energy to motor position conversion"""
403
- ...
404
-
405
-
406
- Apple2Type = TypeVar("Apple2Type", bound=Apple2)
407
-
408
-
409
- class Apple2Controller(abc.ABC, StandardReadable, Generic[Apple2Type]):
410
- """
411
-
412
- Abstract base class for controlling an Apple2 undulator device.
413
-
414
- This class manages the undulator's gap and phase motors, and provides an interface
415
- for controlling polarisation and energy settings. It exposes derived signals for
416
- energy and polarisation, and handles conversion between energy/polarisation and
417
- motor positions via a user-supplied conversion callable.
418
-
419
- Attributes
420
- ----------
421
- apple2 : Reference[Apple2Type]
422
- Reference to the Apple2 device containing gap and phase motors.
423
- energy : derived_signal_rw
424
- Derived signal for moving and reading back energy.
425
- polarisation_setpoint : SignalR
426
- Soft signal for the polarisation setpoint.
427
- polarisation : derived_signal_rw
428
- Hardware-backed signal for polarisation readback and control.
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.
433
-
434
- Abstract Methods
435
- ----------------
436
- _get_apple2_value(gap: float, phase: float) -> Apple2Val
437
- Abstract method to return the Apple2Val used to set the apple2 with.
438
- Notes
439
- -----
440
- - Subclasses must implement `_get_apple2_value` for beamline-specific logic.
441
- - LH3 polarisation is indistinguishable from LH in hardware; special handling is provided.
442
- - Supports multiple polarisation modes, including linear horizontal (LH), linear vertical (LV),
443
- positive circular (PC), negative circular (NC), and linear arbitrary (LA).
444
-
445
- """
446
-
447
- def __init__(
448
- self,
449
- apple2: Apple2Type,
450
- gap_energy_motor_converter: EnergyMotorConvertor,
451
- phase_energy_motor_converter: EnergyMotorConvertor,
452
- units: str = "eV",
453
- name: str = "",
454
- ) -> None:
455
- """
456
-
457
- Parameters
458
- ----------
459
- apple2: Apple2
460
- An Apple2 device.
461
- name: str
462
- Name of the device.
463
- """
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
467
-
468
- # Store the set energy for readback.
469
- self._energy, self._energy_set = soft_signal_r_and_setter(
470
- float, initial_value=None, units=units
471
- )
472
- with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
473
- self.energy = derived_signal_rw(
474
- raw_to_derived=self._read_energy,
475
- set_derived=self._set_energy,
476
- energy=self._energy,
477
- derived_units=units,
478
- )
479
-
480
- # Store the polarisation for setpoint. And provide readback for LH3.
481
- # LH3 is a special case as it is indistinguishable from LH in the hardware.
482
- self.polarisation_setpoint, self._polarisation_setpoint_set = (
483
- soft_signal_r_and_setter(Pol)
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
-
494
- with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
495
- # Hardware backed read/write for polarisation.
496
-
497
- self.polarisation = derived_signal_rw(
498
- raw_to_derived=self._read_pol,
499
- set_derived=self._set_pol,
500
- pol=self.polarisation_setpoint,
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,
505
- gap=self.apple2().gap().user_readback,
506
- )
507
- super().__init__(name)
508
-
509
- @abc.abstractmethod
510
- def _get_apple2_value(self, gap: float, phase: float, pol: Pol) -> Apple2Val:
511
- """
512
- This method should be implemented by the beamline specific ID class as the
513
- motor positions will be different for each beamline depending on the
514
- undulator design.
515
- """
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
-
527
- async def _set_energy(self, energy: float) -> None:
528
- pol = await self._check_and_get_pol_setpoint()
529
- await self._set_motors_from_energy_and_polarisation(energy, pol)
530
- self._energy_set(energy)
531
-
532
- def _read_energy(self, energy: float) -> float:
533
- """Readback for energy is just the set value."""
534
- return energy
535
-
536
- async def _check_and_get_pol_setpoint(self) -> Pol:
537
- """
538
- Check the polarisation setpoint and if it is NONE try to read it from
539
- hardware.
540
- """
541
- pol = await self.polarisation_setpoint.get_value()
542
-
543
- if pol == Pol.NONE:
544
- LOGGER.warning(
545
- "Found no setpoint for polarisation. Attempting to"
546
- " determine polarisation from hardware..."
547
- )
548
- pol = await self.polarisation.get_value()
549
- if pol == Pol.NONE:
550
- raise ValueError(
551
- f"Polarisation cannot be determined from hardware for {self.name}"
552
- )
553
- self._polarisation_setpoint_set(pol)
554
- return pol
555
-
556
- async def _set_pol(
557
- self,
558
- value: Pol,
559
- ) -> None:
560
- # This changes the pol setpoint and then changes polarisation via set energy.
561
- self._polarisation_setpoint_set(value)
562
- await self.energy.set(await self.energy.get_value(), timeout=MAXIMUM_MOVE_TIME)
563
-
564
- def _read_pol(
565
- self,
566
- pol: Pol,
567
- top_outer: float,
568
- top_inner: float,
569
- btm_inner: float,
570
- btm_outer: float,
571
- gap: float,
572
- ) -> Pol:
573
- LOGGER.info(
574
- f"Reading polarisation setpoint from hardware: "
575
- f"top_outer={top_outer}, top_inner={top_inner}, "
576
- f"btm_inner={btm_inner}, btm_outer={btm_outer}, gap={gap}."
577
- )
578
-
579
- read_pol, _ = self.determine_phase_from_hardware(
580
- top_outer, top_inner, btm_inner, btm_outer, gap
581
- )
582
- # LH3 is indistinguishable from LH see determine_phase_from_hardware's docString
583
- # so we return LH3 if the setpoint is LH3 and the readback is LH.
584
- if pol == Pol.LH3 and read_pol == Pol.LH:
585
- LOGGER.info(
586
- "The hardware cannot distinguish between LH and LH3."
587
- " Returning the last commanded polarisation value"
588
- )
589
- return Pol.LH3
590
-
591
- return read_pol
592
-
593
- def determine_phase_from_hardware(
594
- self,
595
- top_outer: float,
596
- top_inner: float,
597
- btm_inner: float,
598
- btm_outer: float,
599
- gap: float,
600
- ) -> tuple[Pol, float]:
601
- """
602
- Determine polarisation and phase value using motor position patterns.
603
- However there is no way to return lh3 polarisation or higher harmonic setting.
604
- (May be for future one can use the inverse poly to work out the energy and try to match it with the current energy
605
- to workout the polarisation but during my test the inverse poly is too unstable for general use.)
606
- """
607
- if gap > MAXIMUM_GAP_MOTOR_POSITION:
608
- raise RuntimeError(
609
- f"{self.name} is not in use, close gap or set polarisation to use this ID"
610
- )
611
-
612
- if all(
613
- isclose(x, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
614
- for x in [top_outer, top_inner, btm_inner, btm_outer]
615
- ):
616
- LOGGER.info("Determined polarisation: LH (Linear Horizontal).")
617
- return Pol.LH, 0.0
618
- if (
619
- isclose(
620
- top_outer,
621
- MAXIMUM_ROW_PHASE_MOTOR_POSITION,
622
- abs_tol=ROW_PHASE_MOTOR_TOLERANCE,
623
- )
624
- and isclose(top_inner, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
625
- and isclose(
626
- btm_inner,
627
- MAXIMUM_ROW_PHASE_MOTOR_POSITION,
628
- abs_tol=ROW_PHASE_MOTOR_TOLERANCE,
629
- )
630
- and isclose(btm_outer, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
631
- ):
632
- LOGGER.info("Determined polarisation: LV (Linear Vertical).")
633
- return Pol.LV, MAXIMUM_ROW_PHASE_MOTOR_POSITION
634
- if (
635
- isclose(top_outer, btm_inner, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
636
- and top_outer > 0.0
637
- and isclose(top_inner, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
638
- and isclose(btm_outer, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
639
- ):
640
- LOGGER.info("Determined polarisation: PC (Positive Circular).")
641
- return Pol.PC, top_outer
642
- if (
643
- isclose(top_outer, btm_inner, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
644
- and top_outer < 0.0
645
- and isclose(top_inner, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
646
- and isclose(btm_outer, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
647
- ):
648
- LOGGER.info("Determined polarisation: NC (Negative Circular).")
649
- return Pol.NC, top_outer
650
- if (
651
- isclose(top_outer, -btm_inner, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
652
- and isclose(top_inner, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
653
- and isclose(btm_outer, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
654
- ):
655
- LOGGER.info("Determined polarisation: LA (Positive Linear Arbitrary).")
656
- return Pol.LA, top_outer
657
- if (
658
- isclose(top_inner, -btm_outer, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
659
- and isclose(top_outer, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
660
- and isclose(btm_inner, 0.0, abs_tol=ROW_PHASE_MOTOR_TOLERANCE)
661
- ):
662
- LOGGER.info("Determined polarisation: LA (Negative Linear Arbitrary).")
663
- return Pol.LA, top_inner
664
-
665
- LOGGER.warning("Unable to determine polarisation. Defaulting to NONE.")
666
- return Pol.NONE, 0.0
667
-
668
-
669
- class InsertionDeviceEnergyBase(abc.ABC, StandardReadable, Movable):
670
- """Base class for ID energy movable device."""
671
-
672
- def __init__(self, name: str = "") -> None:
673
- self.energy: Reference[SignalRW[float]]
674
- super().__init__(name=name)
675
-
676
- @abc.abstractmethod
677
- @AsyncStatus.wrap
678
- async def set(self, energy: float) -> None: ...
679
-
680
-
681
- class BeamEnergy(StandardReadable, Movable[float]):
682
- """
683
- Compound device to set both ID and energy motor at the same time with an option to add an offset.
684
- """
685
-
686
- def __init__(
687
- self, id_energy: InsertionDeviceEnergyBase, mono: Motor, name: str = ""
688
- ) -> None:
689
- """
690
- Parameters
691
- ----------
692
-
693
- id_energy: InsertionDeviceEnergy
694
- An InsertionDeviceEnergy device.
695
- mono: Motor
696
- A Motor(energy) device.
697
- name:
698
- New device name.
699
- """
700
- super().__init__(name=name)
701
- self._id_energy = Reference(id_energy)
702
- self._mono_energy = Reference(mono)
703
-
704
- self.add_readables(
705
- [
706
- self._id_energy().energy(),
707
- self._mono_energy().user_readback,
708
- ],
709
- StandardReadableFormat.HINTED_SIGNAL,
710
- )
711
-
712
- with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
713
- self.id_energy_offset = soft_signal_rw(float, initial_value=0)
714
-
715
- @AsyncStatus.wrap
716
- async def set(self, energy: float) -> None:
717
- LOGGER.info(f"Moving f{self.name} energy to {energy}.")
718
- await asyncio.gather(
719
- self._id_energy().set(
720
- energy=energy + await self.id_energy_offset.get_value()
721
- ),
722
- self._mono_energy().set(energy),
723
- )
724
-
725
-
726
- class InsertionDeviceEnergy(InsertionDeviceEnergyBase):
727
- """Apple2 ID energy movable device."""
728
-
729
- def __init__(self, id_controller: Apple2Controller, name: str = "") -> None:
730
- self.energy = Reference(id_controller.energy)
731
- super().__init__(name=name)
732
-
733
- self.add_readables([self.energy()], StandardReadableFormat.HINTED_SIGNAL)
734
-
735
- @AsyncStatus.wrap
736
- async def set(self, energy: float) -> None:
737
- await self.energy().set(energy, timeout=MAXIMUM_MOVE_TIME)
738
-
739
-
740
- class InsertionDevicePolarisation(StandardReadable, Locatable[Pol]):
741
- """Apple2 ID polarisation movable device."""
742
-
743
- def __init__(self, id_controller: Apple2Controller, name: str = "") -> None:
744
- self.polarisation = Reference(id_controller.polarisation)
745
- self.polarisation_setpoint = Reference(id_controller.polarisation_setpoint)
746
- super().__init__(name=name)
747
-
748
- self.add_readables([self.polarisation()], StandardReadableFormat.HINTED_SIGNAL)
749
-
750
- @AsyncStatus.wrap
751
- async def set(self, pol: Pol) -> None:
752
- await self.polarisation().set(pol, timeout=MAXIMUM_MOVE_TIME)
753
-
754
- async def locate(self) -> Location[Pol]:
755
- """Return the current polarisation"""
756
- setpoint, readback = await asyncio.gather(
757
- self.polarisation_setpoint().get_value(), self.polarisation().get_value()
758
- )
759
- return Location(setpoint=setpoint, readback=readback)