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
dodal/common/maths.py CHANGED
@@ -48,3 +48,83 @@ def in_micros(t: float) -> int:
48
48
  if t < 0:
49
49
  raise ValueError(f"Expected a positive time in seconds, got {t!r}")
50
50
  return int(np.ceil(t * 1e6))
51
+
52
+
53
+ class Rectangle2D:
54
+ """
55
+ A 2D rectangle defined by two opposite corners.
56
+
57
+ This class represents a rectangle in 2D space using two points: (x1, y1) and (x2, y2).
58
+ It provides methods to query rectangle properties and check point containment.
59
+
60
+ Attributes:
61
+ x1 (float): The x-coordinate of the first corner.
62
+ y1 (float): The y-coordinate of the first corner.
63
+ x2 (float): The x-coordinate of the second corner.
64
+ y2 (float): The y-coordinate of the second corner.
65
+ """
66
+
67
+ def __init__(self, x1: float, y1: float, x2: float, y2: float):
68
+ """
69
+ Initialize a Rectangle2D with two corner points.
70
+
71
+ Args:
72
+ x1 (float): The x-coordinate of the first corner.
73
+ y1 (float): The y-coordinate of the first corner.
74
+ x2 (float): The x-coordinate of the second corner.
75
+ y2 (float): The y-coordinate of the second corner.
76
+ """
77
+ self.x1, self.y1 = x1, y1
78
+ self.x2, self.y2 = x2, y2
79
+
80
+ def get_max_x(self) -> float:
81
+ """
82
+ Get the maximum x-coordinate of the rectangle.
83
+
84
+ Returns:
85
+ float: The larger of the two x-coordinates (x1, x2).
86
+ """
87
+ return max(self.x1, self.x2)
88
+
89
+ def get_min_x(self) -> float:
90
+ """
91
+ Get the minimum x-coordinate of the rectangle.
92
+
93
+ Returns:
94
+ float: The smaller of the two x-coordinates (x1, x2).
95
+ """
96
+ return min(self.x1, self.x2)
97
+
98
+ def get_max_y(self) -> float:
99
+ """
100
+ Get the maximum y-coordinate of the rectangle.
101
+
102
+ Returns:
103
+ float: The larger of the two y-coordinates (y1, y2).
104
+ """
105
+ return max(self.y1, self.y2)
106
+
107
+ def get_min_y(self) -> float:
108
+ """
109
+ Get the minimum y-coordinate of the rectangle.
110
+
111
+ Returns:
112
+ float: The smaller of the two y-coordinates (y1, y2).
113
+ """
114
+ return min(self.y1, self.y2)
115
+
116
+ def contains(self, x: float, y: float) -> bool:
117
+ """
118
+ Check if a point is contained within the rectangle.
119
+
120
+ Args:
121
+ x (float): The x-coordinate of the point.
122
+ y (float): The y-coordinate of the point.
123
+
124
+ Returns:
125
+ bool: True if the point is within the rectangle bounds, False otherwise.
126
+ """
127
+ return (
128
+ self.get_min_x() <= x <= self.get_max_x()
129
+ and self.get_min_y() <= y <= self.get_max_y()
130
+ )
dodal/devices/eiger.py CHANGED
@@ -1,11 +1,11 @@
1
- # type: ignore # Eiger will soon be ophyd-async https://github.com/DiamondLightSource/dodal/issues/700
2
1
  from dataclasses import dataclass
3
2
  from enum import Enum
4
3
 
5
4
  from bluesky.protocols import Stageable
6
5
  from ophyd import Component, Device, EpicsSignalRO, Signal
7
6
  from ophyd.areadetector.cam import EigerDetectorCam
8
- from ophyd.status import AndStatus, Status, StatusBase
7
+ from ophyd.signal import AttributeSignal
8
+ from ophyd.status import AndStatus, Status, StatusBase, SubscriptionStatus
9
9
 
10
10
  from dodal.devices.detector import DetectorParams, TriggerMode
11
11
  from dodal.devices.eiger_odin import EigerOdin
@@ -56,7 +56,6 @@ class EigerDetector(Device, Stageable):
56
56
 
57
57
  stale_params = Component(EpicsSignalRO, "CAM:StaleParameters_RBV")
58
58
  bit_depth = Component(EpicsSignalRO, "CAM:BitDepthImage_RBV")
59
-
60
59
  filewriters_finished: StatusBase
61
60
 
62
61
  detector_params: DetectorParams | None = None
@@ -64,9 +63,24 @@ class EigerDetector(Device, Stageable):
64
63
  arming_status = Status()
65
64
  arming_status.set_finished()
66
65
 
67
- def __init__(self, beamline: str = "i03", *args, **kwargs):
66
+ def __init__(
67
+ self,
68
+ beamline: str = "i03",
69
+ ispyb_detector_id: int | None = None,
70
+ *args,
71
+ **kwargs,
72
+ ):
68
73
  super().__init__(*args, **kwargs)
69
74
  self.beamline = beamline
75
+
76
+ self.detector_id = ispyb_detector_id
77
+ self.ispyb_detector_id = AttributeSignal(
78
+ attr="detector_id",
79
+ parent=self,
80
+ name="eiger-ispyb_detector_id",
81
+ write_access=False,
82
+ )
83
+
70
84
  # using i03 timeouts as default
71
85
  self.timeouts = AVAILABLE_TIMEOUTS.get(beamline, AVAILABLE_TIMEOUTS["i03"])
72
86
  self.disarming_status = None
@@ -75,10 +89,11 @@ class EigerDetector(Device, Stageable):
75
89
  def with_params(
76
90
  cls,
77
91
  params: DetectorParams,
78
- name: str = "EigerDetector",
79
92
  beamline: str = "i03",
93
+ ispyb_detector_id: int | None = None,
94
+ name: str = "EigerDetector",
80
95
  ):
81
- det = cls(name=name, beamline=beamline)
96
+ det = cls(name=name, beamline=beamline, ispyb_detector_id=ispyb_detector_id)
82
97
  det.set_detector_parameters(params)
83
98
  return det
84
99
 
@@ -123,7 +138,7 @@ class EigerDetector(Device, Stageable):
123
138
  LOGGER.info("Waiting for arming to finish")
124
139
  self.arming_status.wait(self.timeouts.arming_timeout)
125
140
 
126
- def stage(self):
141
+ def stage(self): # pyright: ignore[reportIncompatibleMethodOverride]
127
142
  self.wait_on_arming_if_started()
128
143
  if not self.is_armed():
129
144
  LOGGER.info("Eiger not armed, arming")
@@ -132,6 +147,7 @@ class EigerDetector(Device, Stageable):
132
147
 
133
148
  def stop_odin_when_all_frames_collected(self):
134
149
  LOGGER.info("Waiting on all frames")
150
+ assert self.detector_params
135
151
  try:
136
152
  await_value(
137
153
  self.odin.file_writer.num_captured,
@@ -141,7 +157,7 @@ class EigerDetector(Device, Stageable):
141
157
  LOGGER.info("Stopping Odin")
142
158
  self.odin.stop().wait(self.timeouts.odin_stop_timeout)
143
159
 
144
- def unstage(self) -> bool:
160
+ def unstage(self) -> bool: # pyright: ignore[reportIncompatibleMethodOverride]
145
161
  assert self.detector_params is not None
146
162
  try:
147
163
  self.disarming_status = Status()
@@ -166,7 +182,7 @@ class EigerDetector(Device, Stageable):
166
182
  self.disarming_status.set_finished()
167
183
  return status_ok
168
184
 
169
- def stop(self, *args):
185
+ def stop(self, *args): # pyright: ignore[reportIncompatibleMethodOverride]
170
186
  """Emergency stop the device, mainly used to clean up after error."""
171
187
  LOGGER.info("Eiger stop() called - cleaning up...")
172
188
  if self.disarming_status and not self.disarming_status.done:
@@ -241,7 +257,8 @@ class EigerDetector(Device, Stageable):
241
257
  1, timeout=self.timeouts.general_status_timeout
242
258
  )
243
259
  status &= self.cam.image_mode.set(
244
- self.cam.ImageMode.MULTIPLE, timeout=self.timeouts.general_status_timeout
260
+ self.cam.ImageMode.MULTIPLE, # pyright: ignore[reportAttributeAccessIssue]
261
+ timeout=self.timeouts.general_status_timeout,
245
262
  )
246
263
  status &= self.cam.trigger_mode.set(
247
264
  InternalEigerTriggerMode.EXTERNAL_SERIES.value,
@@ -285,25 +302,24 @@ class EigerDetector(Device, Stageable):
285
302
  beam_x_pixels, beam_y_pixels = self.detector_params.get_beam_position_pixels(
286
303
  self.detector_params.detector_distance
287
304
  )
288
- status = self.cam.beam_center_x.set(
305
+ self.cam.beam_center_x.set(
289
306
  beam_x_pixels, timeout=self.timeouts.general_status_timeout
290
- )
291
- status &= self.cam.beam_center_y.set(
307
+ ).wait(timeout=self.timeouts.general_status_timeout)
308
+ self.cam.beam_center_y.set(
292
309
  beam_y_pixels, timeout=self.timeouts.general_status_timeout
293
- )
294
- status &= self.cam.det_distance.set(
310
+ ).wait(timeout=self.timeouts.general_status_timeout)
311
+ self.cam.det_distance.set(
295
312
  self.detector_params.detector_distance,
296
313
  timeout=self.timeouts.general_status_timeout,
297
- )
298
- status &= self.cam.omega_start.set(
314
+ ).wait(timeout=self.timeouts.general_status_timeout)
315
+ self.cam.omega_start.set(
299
316
  self.detector_params.omega_start,
300
317
  timeout=self.timeouts.general_status_timeout,
301
- )
302
- status &= self.cam.omega_incr.set(
318
+ ).wait(timeout=self.timeouts.general_status_timeout)
319
+ status = self.cam.omega_incr.set(
303
320
  self.detector_params.omega_increment,
304
321
  timeout=self.timeouts.general_status_timeout,
305
322
  )
306
-
307
323
  return status
308
324
 
309
325
  def set_detector_threshold(self, energy: float, tolerance: float = 0.1) -> Status:
@@ -315,7 +331,7 @@ class EigerDetector(Device, Stageable):
315
331
  this tolerance it is not set again. Defaults to 0.1eV.
316
332
  """
317
333
 
318
- current_energy = self.cam.photon_energy.get()
334
+ current_energy = float(self.cam.photon_energy.get())
319
335
  if abs(current_energy - energy) > tolerance:
320
336
  LOGGER.info(f"Setting detector threshold to {energy}")
321
337
  return self.cam.photon_energy.set(
@@ -398,7 +414,7 @@ class EigerDetector(Device, Stageable):
398
414
  def disarm_detector(self):
399
415
  self.cam.acquire.set(0).wait(self.timeouts.general_status_timeout)
400
416
 
401
- def wait_for_stale_params(self) -> Status:
417
+ def wait_for_stale_params(self) -> SubscriptionStatus:
402
418
  LOGGER.info("Eiger arming: Waiting for stale params...")
403
419
  return await_value(self.stale_params, 0, 60)
404
420
 
@@ -412,6 +428,11 @@ class EigerDetector(Device, Stageable):
412
428
  detector_params: DetectorParams = self.detector_params
413
429
  if detector_params.use_roi_mode:
414
430
  functions_to_do_arm.append(self.enable_roi_mode)
431
+ threshold_energy = (
432
+ detector_params.expected_energy_ev
433
+ if detector_params.expected_energy_ev
434
+ else float(self.cam.photon_energy.get())
435
+ )
415
436
 
416
437
  arming_sequence_funcs = [
417
438
  # If a beam dump occurs after arming the eiger but prior to eiger staging,
@@ -419,7 +440,7 @@ class EigerDetector(Device, Stageable):
419
440
  # if this previously completed successfully we must reset the odin first
420
441
  self.odin.stop,
421
442
  lambda: self.change_dev_shm(detector_params.enable_dev_shm),
422
- lambda: self.set_detector_threshold(detector_params.expected_energy_ev),
443
+ lambda: self.set_detector_threshold(threshold_energy),
423
444
  self.set_cam_pvs,
424
445
  self.set_odin_number_of_frame_chunks,
425
446
  self.set_odin_pvs,
@@ -1,33 +0,0 @@
1
- from .detector import (
2
- ElectronAnalyserDetector,
3
- ElectronAnalyserRegionDetector,
4
- TElectronAnalyserDetector,
5
- TElectronAnalyserRegionDetector,
6
- )
7
- from .energy_sources import DualEnergySource, EnergySource
8
- from .enums import EnergyMode, SelectedSource
9
- from .types import (
10
- ElectronAnalyserDetectorImpl,
11
- ElectronAnalyserDriverImpl,
12
- GenericElectronAnalyserDetector,
13
- GenericElectronAnalyserRegionDetector,
14
- )
15
- from .util import to_binding_energy, to_kinetic_energy
16
-
17
- __all__ = [
18
- "to_binding_energy",
19
- "to_kinetic_energy",
20
- "DualEnergySource",
21
- "SelectedSource",
22
- "EnergySource",
23
- "EnergyMode",
24
- "SelectedSource",
25
- "ElectronAnalyserDetector",
26
- "ElectronAnalyserDetectorImpl",
27
- "ElectronAnalyserDriverImpl",
28
- "TElectronAnalyserDetector",
29
- "ElectronAnalyserRegionDetector",
30
- "TElectronAnalyserRegionDetector",
31
- "GenericElectronAnalyserDetector",
32
- "GenericElectronAnalyserRegionDetector",
33
- ]
@@ -0,0 +1,58 @@
1
+ from .base_controller import (
2
+ ElectronAnalyserController,
3
+ GenericElectronAnalyserController,
4
+ )
5
+ from .base_detector import (
6
+ BaseElectronAnalyserDetector,
7
+ ElectronAnalyserDetector,
8
+ ElectronAnalyserRegionDetector,
9
+ GenericBaseElectronAnalyserDetector,
10
+ GenericElectronAnalyserDetector,
11
+ GenericElectronAnalyserRegionDetector,
12
+ )
13
+ from .base_driver_io import (
14
+ AbstractAnalyserDriverIO,
15
+ GenericAnalyserDriverIO,
16
+ TAbstractAnalyserDriverIO,
17
+ )
18
+ from .base_enums import EnergyMode
19
+ from .base_region import (
20
+ AbstractBaseRegion,
21
+ AbstractBaseSequence,
22
+ GenericRegion,
23
+ GenericSequence,
24
+ TAbstractBaseRegion,
25
+ TAbstractBaseSequence,
26
+ TAcquisitionMode,
27
+ TLensMode,
28
+ )
29
+ from .base_util import to_binding_energy, to_kinetic_energy
30
+ from .energy_sources import AbstractEnergySource, DualEnergySource, EnergySource
31
+
32
+ __all__ = [
33
+ "ElectronAnalyserController",
34
+ "GenericElectronAnalyserController",
35
+ "BaseElectronAnalyserDetector",
36
+ "ElectronAnalyserDetector",
37
+ "ElectronAnalyserRegionDetector",
38
+ "GenericBaseElectronAnalyserDetector",
39
+ "GenericElectronAnalyserDetector",
40
+ "GenericElectronAnalyserRegionDetector",
41
+ "AbstractAnalyserDriverIO",
42
+ "GenericAnalyserDriverIO",
43
+ "TAbstractAnalyserDriverIO",
44
+ "EnergyMode",
45
+ "AbstractBaseRegion",
46
+ "AbstractBaseSequence",
47
+ "GenericRegion",
48
+ "GenericSequence",
49
+ "TAbstractBaseRegion",
50
+ "TAbstractBaseSequence",
51
+ "TAcquisitionMode",
52
+ "TLensMode",
53
+ "to_binding_energy",
54
+ "to_kinetic_energy",
55
+ "AbstractEnergySource",
56
+ "DualEnergySource",
57
+ "EnergySource",
58
+ ]
@@ -0,0 +1,84 @@
1
+ from typing import Generic, TypeVar
2
+
3
+ from ophyd_async.core import TriggerInfo
4
+ from ophyd_async.epics.adcore import ADImageMode
5
+
6
+ from dodal.devices.controllers import ConstantDeadTimeController
7
+ from dodal.devices.electron_analyser.base.base_driver_io import (
8
+ GenericAnalyserDriverIO,
9
+ TAbstractAnalyserDriverIO,
10
+ )
11
+ from dodal.devices.electron_analyser.base.base_region import (
12
+ GenericRegion,
13
+ TAbstractBaseRegion,
14
+ )
15
+ from dodal.devices.electron_analyser.base.energy_sources import AbstractEnergySource
16
+ from dodal.devices.fast_shutter import FastShutter
17
+ from dodal.devices.selectable_source import SourceSelector
18
+
19
+
20
+ class ElectronAnalyserController(
21
+ ConstantDeadTimeController[TAbstractAnalyserDriverIO],
22
+ Generic[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
23
+ ):
24
+ """
25
+ Specialised controller for the electron analysers to provide additional setup logic
26
+ such as selecting the energy source to use from requested region and giving the
27
+ driver the correct region parameters.
28
+ """
29
+
30
+ def __init__(
31
+ self,
32
+ driver: TAbstractAnalyserDriverIO,
33
+ energy_source: AbstractEnergySource,
34
+ shutter: FastShutter | None = None,
35
+ source_selector: SourceSelector | None = None,
36
+ deadtime: float = 0,
37
+ image_mode: ADImageMode = ADImageMode.SINGLE,
38
+ ):
39
+ """
40
+ Parameters:
41
+ driver: The electron analyser driver to wrap around that holds the PV's.
42
+ energy_source: Device that holds the excitation energy and ability to switch
43
+ between sources.
44
+ deadtime: For a given exposure, what is the safest minimum time between
45
+ exposures that can be determined without reading signals.
46
+ image_mode: The image mode to configure the driver with before measuring.
47
+ """
48
+ self.energy_source = energy_source
49
+ self.shutter = shutter
50
+ self.source_selector = source_selector
51
+ super().__init__(driver, deadtime, image_mode)
52
+
53
+ async def setup_with_region(self, region: TAbstractBaseRegion) -> None:
54
+ """Logic to set the driver with a region."""
55
+ if self.source_selector is not None:
56
+ await self.source_selector.set(region.excitation_energy_source)
57
+
58
+ # Should this be moved to a VGScientController only?
59
+ if self.shutter is not None:
60
+ await self.shutter.set(self.shutter.close_state)
61
+
62
+ excitation_energy = await self.energy_source.energy.get_value()
63
+ epics_region = region.prepare_for_epics(excitation_energy)
64
+ await self.driver.set(epics_region)
65
+
66
+ async def prepare(self, trigger_info: TriggerInfo) -> None:
67
+ """Do all necessary steps to prepare the detector for triggers."""
68
+ # Let the driver know the excitation energy before measuring for binding energy
69
+ # axis calculation.
70
+ excitation_energy = await self.energy_source.energy.get_value()
71
+ await self.driver.cached_excitation_energy.set(excitation_energy)
72
+
73
+ if self.shutter is not None:
74
+ await self.shutter.set(self.shutter.open_state)
75
+
76
+ await super().prepare(trigger_info)
77
+
78
+
79
+ GenericElectronAnalyserController = ElectronAnalyserController[
80
+ GenericAnalyserDriverIO, GenericRegion
81
+ ]
82
+ TElectronAnalyserController = TypeVar(
83
+ "TElectronAnalyserController", bound=ElectronAnalyserController
84
+ )
@@ -0,0 +1,214 @@
1
+ from typing import Generic, TypeVar
2
+
3
+ from bluesky.protocols import Reading, Stageable, Triggerable
4
+ from event_model import DataKey
5
+ from ophyd_async.core import (
6
+ AsyncConfigurable,
7
+ AsyncReadable,
8
+ AsyncStatus,
9
+ Device,
10
+ TriggerInfo,
11
+ )
12
+
13
+ from dodal.common.data_util import load_json_file_to_class
14
+ from dodal.devices.electron_analyser.base.base_controller import (
15
+ ElectronAnalyserController,
16
+ )
17
+ from dodal.devices.electron_analyser.base.base_driver_io import (
18
+ GenericAnalyserDriverIO,
19
+ TAbstractAnalyserDriverIO,
20
+ )
21
+ from dodal.devices.electron_analyser.base.base_region import (
22
+ GenericRegion,
23
+ GenericSequence,
24
+ TAbstractBaseRegion,
25
+ TAbstractBaseSequence,
26
+ )
27
+
28
+
29
+ class BaseElectronAnalyserDetector(
30
+ Device,
31
+ Triggerable,
32
+ AsyncReadable,
33
+ AsyncConfigurable,
34
+ Generic[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
35
+ ):
36
+ """
37
+ Detector for data acquisition of electron analyser. Can only acquire using settings
38
+ already configured for the device.
39
+
40
+ If possible, this should be changed to inherit from a StandardDetector. Currently,
41
+ StandardDetector forces you to use a file writer which doesn't apply here.
42
+ See issue https://github.com/bluesky/ophyd-async/issues/888
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ controller: ElectronAnalyserController[
48
+ TAbstractAnalyserDriverIO, TAbstractBaseRegion
49
+ ],
50
+ name: str = "",
51
+ ):
52
+ self._controller = controller
53
+ super().__init__(name)
54
+
55
+ @AsyncStatus.wrap
56
+ async def set(self, region: TAbstractBaseRegion) -> None:
57
+ await self._controller.setup_with_region(region)
58
+
59
+ @AsyncStatus.wrap
60
+ async def trigger(self) -> None:
61
+ await self._controller.prepare(TriggerInfo())
62
+ await self._controller.arm()
63
+ await self._controller.wait_for_idle()
64
+
65
+ async def read(self) -> dict[str, Reading]:
66
+ return await self._controller.driver.read()
67
+
68
+ async def describe(self) -> dict[str, DataKey]:
69
+ data = await self._controller.driver.describe()
70
+ # Correct the shape for image
71
+ prefix = self._controller.driver.name + "-"
72
+ energy_size = len(await self._controller.driver.energy_axis.get_value())
73
+ angle_size = len(await self._controller.driver.angle_axis.get_value())
74
+ data[prefix + "image"]["shape"] = [angle_size, energy_size]
75
+ return data
76
+
77
+ async def read_configuration(self) -> dict[str, Reading]:
78
+ return await self._controller.driver.read_configuration()
79
+
80
+ async def describe_configuration(self) -> dict[str, DataKey]:
81
+ return await self._controller.driver.describe_configuration()
82
+
83
+
84
+ GenericBaseElectronAnalyserDetector = BaseElectronAnalyserDetector[
85
+ GenericAnalyserDriverIO, GenericRegion
86
+ ]
87
+
88
+
89
+ class ElectronAnalyserRegionDetector(
90
+ BaseElectronAnalyserDetector[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
91
+ Generic[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
92
+ ):
93
+ """
94
+ Extends electron analyser detector to configure specific region settings before data
95
+ acquisition. It is designed to only exist inside a plan.
96
+ """
97
+
98
+ def __init__(
99
+ self,
100
+ controller: ElectronAnalyserController[
101
+ TAbstractAnalyserDriverIO, TAbstractBaseRegion
102
+ ],
103
+ region: TAbstractBaseRegion,
104
+ name: str = "",
105
+ ):
106
+ self.region = region
107
+ super().__init__(controller, name)
108
+
109
+ @AsyncStatus.wrap
110
+ async def trigger(self) -> None:
111
+ # Configure region parameters on the driver first before data collection.
112
+ await self.set(self.region)
113
+ await super().trigger()
114
+
115
+
116
+ GenericElectronAnalyserRegionDetector = ElectronAnalyserRegionDetector[
117
+ GenericAnalyserDriverIO, GenericRegion
118
+ ]
119
+ TElectronAnalyserRegionDetector = TypeVar(
120
+ "TElectronAnalyserRegionDetector",
121
+ bound=ElectronAnalyserRegionDetector,
122
+ )
123
+
124
+
125
+ class ElectronAnalyserDetector(
126
+ BaseElectronAnalyserDetector[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
127
+ Stageable,
128
+ Generic[TAbstractBaseSequence, TAbstractAnalyserDriverIO, TAbstractBaseRegion],
129
+ ):
130
+ """
131
+ Electron analyser detector with the additional functionality to load a sequence file
132
+ and create a list of temporary ElectronAnalyserRegionDetector objects. These will
133
+ setup configured region settings before data acquisition.
134
+ """
135
+
136
+ def __init__(
137
+ self,
138
+ sequence_class: type[TAbstractBaseSequence],
139
+ controller: ElectronAnalyserController[
140
+ TAbstractAnalyserDriverIO, TAbstractBaseRegion
141
+ ],
142
+ name: str = "",
143
+ ):
144
+ self._sequence_class = sequence_class
145
+ super().__init__(controller, name)
146
+
147
+ @AsyncStatus.wrap
148
+ async def stage(self) -> None:
149
+ """
150
+ Prepare the detector for use by ensuring it is idle and ready.
151
+
152
+ This method asynchronously stages the detector by first disarming the controller
153
+ to ensure the detector is not actively acquiring data, then invokes the driver's
154
+ stage procedure. This ensures the detector is in a known, ready state
155
+ before use.
156
+
157
+ Raises:
158
+ Any exceptions raised by the driver's stage or controller's disarm methods.
159
+ """
160
+ await self._controller.disarm()
161
+
162
+ @AsyncStatus.wrap
163
+ async def unstage(self) -> None:
164
+ """Disarm the detector."""
165
+ await self._controller.disarm()
166
+
167
+ def load_sequence(self, filename: str) -> TAbstractBaseSequence:
168
+ """
169
+ Load the sequence data from a provided json file into a sequence class.
170
+
171
+ Args:
172
+ filename: Path to the sequence file containing the region data.
173
+
174
+ Returns:
175
+ Pydantic model representing the sequence file.
176
+ """
177
+ return load_json_file_to_class(self._sequence_class, filename)
178
+
179
+ def create_region_detector_list(
180
+ self, filename: str, enabled_only=True
181
+ ) -> list[
182
+ ElectronAnalyserRegionDetector[TAbstractAnalyserDriverIO, TAbstractBaseRegion]
183
+ ]:
184
+ """
185
+ Create a list of detectors equal to the number of regions in a sequence file.
186
+ Each detector is responsible for setting up a specific region.
187
+
188
+ Args:
189
+ filename: Path to the sequence file containing the region data.
190
+ enabled_only: If true, only include the region if enabled is True.
191
+
192
+ Returns:
193
+ List of ElectronAnalyserRegionDetector, equal to the number of regions in
194
+ the sequence file.
195
+ """
196
+ seq = self.load_sequence(filename)
197
+ regions: list[TAbstractBaseRegion] = (
198
+ seq.get_enabled_regions() if enabled_only else seq.regions
199
+ )
200
+ return [
201
+ ElectronAnalyserRegionDetector[
202
+ TAbstractAnalyserDriverIO, TAbstractBaseRegion
203
+ ](self._controller, r, self.name + "_" + r.name)
204
+ for r in regions
205
+ ]
206
+
207
+
208
+ GenericElectronAnalyserDetector = ElectronAnalyserDetector[
209
+ GenericSequence, GenericAnalyserDriverIO, GenericRegion
210
+ ]
211
+ TElectronAnalyserDetector = TypeVar(
212
+ "TElectronAnalyserDetector",
213
+ bound=ElectronAnalyserDetector,
214
+ )