dls-dodal 1.60.0__py3-none-any.whl → 1.62.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 (45) hide show
  1. {dls_dodal-1.60.0.dist-info → dls_dodal-1.62.0.dist-info}/METADATA +1 -1
  2. {dls_dodal-1.60.0.dist-info → dls_dodal-1.62.0.dist-info}/RECORD +45 -30
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/i04.py +1 -1
  5. dodal/beamlines/i19_2.py +10 -0
  6. dodal/devices/apple2_undulator.py +85 -52
  7. dodal/devices/areadetector/__init__.py +0 -0
  8. dodal/devices/areadetector/plugins/__init__.py +0 -0
  9. dodal/devices/attenuator/__init__.py +0 -0
  10. dodal/devices/controllers.py +8 -6
  11. dodal/devices/electron_analyser/abstract/__init__.py +2 -2
  12. dodal/devices/electron_analyser/abstract/base_detector.py +13 -26
  13. dodal/devices/electron_analyser/abstract/base_driver_io.py +5 -4
  14. dodal/devices/electron_analyser/abstract/base_region.py +28 -13
  15. dodal/devices/electron_analyser/detector.py +19 -31
  16. dodal/devices/electron_analyser/specs/driver_io.py +0 -1
  17. dodal/devices/electron_analyser/vgscienta/driver_io.py +0 -1
  18. dodal/devices/fast_grid_scan.py +111 -32
  19. dodal/devices/fast_shutter.py +57 -0
  20. dodal/devices/i02_1/fast_grid_scan.py +1 -1
  21. dodal/devices/i04/murko_results.py +24 -12
  22. dodal/devices/i10/i10_apple2.py +15 -15
  23. dodal/devices/i10/rasor/__init__.py +0 -0
  24. dodal/devices/i11/__init__.py +0 -0
  25. dodal/devices/i15/__init__.py +0 -0
  26. dodal/devices/i15/dcm.py +10 -9
  27. dodal/devices/i15/focussing_mirror.py +4 -20
  28. dodal/devices/i15/jack.py +2 -10
  29. dodal/devices/i15/laue.py +1 -5
  30. dodal/devices/i15/multilayer_mirror.py +1 -5
  31. dodal/devices/i15/rail.py +1 -5
  32. dodal/devices/i18/__init__.py +0 -0
  33. dodal/devices/i19/mapt_configuration.py +38 -0
  34. dodal/devices/i19/pin_col_stages.py +170 -0
  35. dodal/devices/i22/__init__.py +0 -0
  36. dodal/devices/i24/commissioning_jungfrau.py +9 -1
  37. dodal/devices/mx_phase1/__init__.py +0 -0
  38. dodal/devices/oav/snapshots/__init__.py +0 -0
  39. dodal/devices/xspress3/__init__.py +0 -0
  40. dodal/parameters/__init__.py +0 -0
  41. dodal/plans/configure_arm_trigger_and_disarm_detector.py +27 -5
  42. {dls_dodal-1.60.0.dist-info → dls_dodal-1.62.0.dist-info}/WHEEL +0 -0
  43. {dls_dodal-1.60.0.dist-info → dls_dodal-1.62.0.dist-info}/entry_points.txt +0 -0
  44. {dls_dodal-1.60.0.dist-info → dls_dodal-1.62.0.dist-info}/licenses/LICENSE +0 -0
  45. {dls_dodal-1.60.0.dist-info → dls_dodal-1.62.0.dist-info}/top_level.txt +0 -0
@@ -1,7 +1,7 @@
1
1
  import re
2
2
  from abc import ABC
3
3
  from collections.abc import Callable
4
- from typing import Generic, TypeVar
4
+ from typing import Generic, Self, TypeVar
5
5
 
6
6
  from pydantic import BaseModel, Field, model_validator
7
7
 
@@ -88,28 +88,43 @@ class AbstractBaseRegion(
88
88
  return self.energy_mode == EnergyMode.KINETIC
89
89
 
90
90
  def switch_energy_mode(
91
- self, energy_mode: EnergyMode, excitation_energy: float
92
- ) -> None:
91
+ self, energy_mode: EnergyMode, excitation_energy: float, copy: bool = True
92
+ ) -> Self:
93
93
  """
94
- Switch region to new energy mode: Kinetic or Binding. Updates the low_energy,
95
- centre_energy, high_energy, and energy_mode, only if it switches to a new one.
94
+ Switch region with to a new energy mode with a new energy mode: Kinetic or Binding.
95
+ It caculates new values for low_energy, centre_energy, high_energy, via the
96
+ excitation enerrgy. It doesn't calculate anything if the region is already of
97
+ the same energy mode.
96
98
 
97
99
  Parameters:
98
- energy_mode: mode you want to switch the region to.
99
- excitation_energy: the energy to calculate the new values of low_energy,
100
- centre_energy, and high_energy.
100
+ energy_mode: Mode you want to switch the region to.
101
+ excitation_energy: Energy conversion for low_energy, centre_energy, and
102
+ high_energy for new energy mode.
103
+ copy: Defaults to True. If true, create a copy of this region for the new
104
+ energy_mode and return it. If False, alter this region for the
105
+ energy_mode and return it self.
106
+
107
+ Returns:
108
+ Region with selected energy mode and new calculated energy values.
101
109
  """
110
+ switched_r = self.model_copy() if copy else self
102
111
  conv = (
103
112
  to_binding_energy
104
113
  if energy_mode == EnergyMode.BINDING
105
114
  else to_kinetic_energy
106
115
  )
107
- self.low_energy = conv(self.low_energy, self.energy_mode, excitation_energy)
108
- self.centre_energy = conv(
109
- self.centre_energy, self.energy_mode, excitation_energy
116
+ switched_r.low_energy = conv(
117
+ switched_r.low_energy, switched_r.energy_mode, excitation_energy
110
118
  )
111
- self.high_energy = conv(self.high_energy, self.energy_mode, excitation_energy)
112
- self.energy_mode = energy_mode
119
+ switched_r.centre_energy = conv(
120
+ switched_r.centre_energy, switched_r.energy_mode, excitation_energy
121
+ )
122
+ switched_r.high_energy = conv(
123
+ switched_r.high_energy, switched_r.energy_mode, excitation_energy
124
+ )
125
+ switched_r.energy_mode = energy_mode
126
+
127
+ return switched_r
113
128
 
114
129
  @model_validator(mode="before")
115
130
  @classmethod
@@ -1,14 +1,13 @@
1
1
  from typing import Generic, TypeVar
2
2
 
3
3
  from bluesky.protocols import Stageable
4
- from ophyd_async.core import (
5
- AsyncStatus,
6
- Reference,
7
- )
4
+ from ophyd_async.core import AsyncStatus
5
+ from ophyd_async.epics.adcore import ADBaseController
8
6
 
9
7
  from dodal.common.data_util import load_json_file_to_class
8
+ from dodal.devices.controllers import ConstantDeadTimeController
10
9
  from dodal.devices.electron_analyser.abstract.base_detector import (
11
- AbstractElectronAnalyserDetector,
10
+ BaseElectronAnalyserDetector,
12
11
  )
13
12
  from dodal.devices.electron_analyser.abstract.base_driver_io import (
14
13
  TAbstractAnalyserDriverIO,
@@ -20,35 +19,27 @@ from dodal.devices.electron_analyser.abstract.base_region import (
20
19
 
21
20
 
22
21
  class ElectronAnalyserRegionDetector(
23
- AbstractElectronAnalyserDetector[TAbstractAnalyserDriverIO],
22
+ BaseElectronAnalyserDetector[TAbstractAnalyserDriverIO],
24
23
  Generic[TAbstractAnalyserDriverIO, TAbstractBaseRegion],
25
24
  ):
26
25
  """
27
26
  Extends electron analyser detector to configure specific region settings before data
28
- acqusition. This object must be passed in a driver and store it as a reference. It
29
- is designed to only exist inside a plan.
27
+ acquisition. It is designed to only exist inside a plan.
30
28
  """
31
29
 
32
30
  def __init__(
33
31
  self,
34
- driver: TAbstractAnalyserDriverIO,
32
+ controller: ADBaseController[TAbstractAnalyserDriverIO],
35
33
  region: TAbstractBaseRegion,
36
34
  name: str = "",
37
35
  ):
38
- self._driver_ref = Reference(driver)
39
36
  self.region = region
40
- super().__init__(driver, name)
41
-
42
- @property
43
- def driver(self) -> TAbstractAnalyserDriverIO:
44
- # Store as a reference, this implementation will be given a driver so needs to
45
- # make sure we don't get conflicting parents.
46
- return self._driver_ref()
37
+ super().__init__(controller, name)
47
38
 
48
39
  @AsyncStatus.wrap
49
40
  async def trigger(self) -> None:
50
41
  # Configure region parameters on the driver first before data collection.
51
- await self.driver.set(self.region)
42
+ await self._controller.driver.set(self.region)
52
43
  await super().trigger()
53
44
 
54
45
 
@@ -59,7 +50,7 @@ TElectronAnalyserRegionDetector = TypeVar(
59
50
 
60
51
 
61
52
  class ElectronAnalyserDetector(
62
- AbstractElectronAnalyserDetector[TAbstractAnalyserDriverIO],
53
+ BaseElectronAnalyserDetector[TAbstractAnalyserDriverIO],
63
54
  Stageable,
64
55
  Generic[
65
56
  TAbstractAnalyserDriverIO,
@@ -79,16 +70,11 @@ class ElectronAnalyserDetector(
79
70
  driver: TAbstractAnalyserDriverIO,
80
71
  name: str = "",
81
72
  ):
82
- # Pass in driver
83
- self._driver = driver
73
+ # Save driver as direct child so participates with connect()
74
+ self.driver = driver
84
75
  self._sequence_class = sequence_class
85
- super().__init__(self.driver, name)
86
-
87
- @property
88
- def driver(self) -> TAbstractAnalyserDriverIO:
89
- # This implementation creates the driver and wants this to be the parent so it
90
- # can be used with connect() method.
91
- return self._driver
76
+ controller = ConstantDeadTimeController[TAbstractAnalyserDriverIO](driver, 0)
77
+ super().__init__(controller, name)
92
78
 
93
79
  @AsyncStatus.wrap
94
80
  async def stage(self) -> None:
@@ -103,13 +89,13 @@ class ElectronAnalyserDetector(
103
89
  Raises:
104
90
  Any exceptions raised by the driver's stage or controller's disarm methods.
105
91
  """
106
- await self.controller.disarm()
92
+ await self._controller.disarm()
107
93
  await self.driver.stage()
108
94
 
109
95
  @AsyncStatus.wrap
110
96
  async def unstage(self) -> None:
111
97
  """Disarm the detector."""
112
- await self.controller.disarm()
98
+ await self._controller.disarm()
113
99
  await self.driver.unstage()
114
100
 
115
101
  def load_sequence(self, filename: str) -> TAbstractBaseSequence:
@@ -144,7 +130,9 @@ class ElectronAnalyserDetector(
144
130
  seq = self.load_sequence(filename)
145
131
  regions = seq.get_enabled_regions() if enabled_only else seq.regions
146
132
  return [
147
- ElectronAnalyserRegionDetector(self.driver, r, self.name + "_" + r.name)
133
+ ElectronAnalyserRegionDetector(
134
+ self._controller, r, self.name + "_" + r.name
135
+ )
148
136
  for r in regions
149
137
  ]
150
138
 
@@ -66,7 +66,6 @@ class SpecsAnalyserDriverIO(
66
66
  async def _set_region(self, ke_region: SpecsRegion[TLensMode, TPsuMode]):
67
67
  await asyncio.gather(
68
68
  self.region_name.set(ke_region.name),
69
- self.energy_mode.set(ke_region.energy_mode),
70
69
  self.low_energy.set(ke_region.low_energy),
71
70
  self.high_energy.set(ke_region.high_energy),
72
71
  self.slices.set(ke_region.slices),
@@ -74,7 +74,6 @@ class VGScientaAnalyserDriverIO(
74
74
  async def _set_region(self, ke_region: VGScientaRegion[TLensMode, TPassEnergyEnum]):
75
75
  await asyncio.gather(
76
76
  self.region_name.set(ke_region.name),
77
- self.energy_mode.set(ke_region.energy_mode),
78
77
  self.low_energy.set(ke_region.low_energy),
79
78
  self.centre_energy.set(ke_region.centre_energy),
80
79
  self.high_energy.set(ke_region.high_energy),
@@ -1,9 +1,10 @@
1
+ import asyncio
1
2
  from abc import ABC, abstractmethod
2
3
  from typing import Generic, TypeVar
3
4
 
4
5
  import numpy as np
5
- from bluesky.plan_stubs import mv
6
- from bluesky.protocols import Flyable
6
+ from bluesky.plan_stubs import prepare
7
+ from bluesky.protocols import Flyable, Preparable
7
8
  from numpy import ndarray
8
9
  from ophyd_async.core import (
9
10
  AsyncStatus,
@@ -13,6 +14,7 @@ from ophyd_async.core import (
13
14
  SignalRW,
14
15
  StandardReadable,
15
16
  derived_signal_r,
17
+ set_and_wait_for_value,
16
18
  soft_signal_r_and_setter,
17
19
  wait_for_value,
18
20
  )
@@ -29,6 +31,10 @@ from dodal.log import LOGGER
29
31
  from dodal.parameters.experiment_parameter_base import AbstractExperimentWithBeamParams
30
32
 
31
33
 
34
+ class GridScanInvalidException(RuntimeError):
35
+ """Raised when the gridscan parameters are not valid."""
36
+
37
+
32
38
  @dataclass
33
39
  class GridAxis:
34
40
  start: float
@@ -144,7 +150,7 @@ class GridScanParamsThreeD(GridScanParamsCommon):
144
150
  return GridAxis(self.z2_start_mm, self.z_step_size_mm, self.z_steps)
145
151
 
146
152
 
147
- ParamType = TypeVar("ParamType", bound=GridScanParamsCommon, covariant=True)
153
+ ParamType = TypeVar("ParamType", bound=GridScanParamsCommon)
148
154
 
149
155
 
150
156
  class WithDwellTime(BaseModel):
@@ -190,7 +196,9 @@ class MotionProgram(Device):
190
196
  self.program_number = soft_signal_r_and_setter(float, -1)[0]
191
197
 
192
198
 
193
- class FastGridScanCommon(StandardReadable, Flyable, ABC, Generic[ParamType]):
199
+ class FastGridScanCommon(
200
+ StandardReadable, Flyable, ABC, Preparable, Generic[ParamType]
201
+ ):
194
202
  """Device containing the minimal signals for a general fast grid scan.
195
203
 
196
204
  When the motion program is started, the goniometer will move in a snake-like grid trajectory,
@@ -231,8 +239,9 @@ class FastGridScanCommon(StandardReadable, Flyable, ABC, Generic[ParamType]):
231
239
  self.KICKOFF_TIMEOUT: float = 5.0
232
240
 
233
241
  self.COMPLETE_STATUS: float = 60.0
242
+ self.VALIDITY_CHECK_TIMEOUT = 0.5
234
243
 
235
- self.movable_params: dict[str, Signal] = {
244
+ self._movable_params: dict[str, Signal] = {
236
245
  "x_steps": self.x_steps,
237
246
  "y_steps": self.y_steps,
238
247
  "x_step_size_mm": self.x_step_size,
@@ -284,6 +293,45 @@ class FastGridScanCommon(StandardReadable, Flyable, ABC, Generic[ParamType]):
284
293
  self, motion_controller_prefix: str
285
294
  ) -> MotionProgram: ...
286
295
 
296
+ @AsyncStatus.wrap
297
+ async def prepare(self, value: ParamType):
298
+ """
299
+ Submit the gridscan parameters to the device for validation prior to
300
+ gridscan kickoff
301
+ Args:
302
+ value: the gridscan parameters
303
+
304
+ Raises:
305
+ GridScanInvalidException: if the gridscan parameters were not valid
306
+ """
307
+ set_statuses = []
308
+
309
+ LOGGER.info("Applying gridscan parameters...")
310
+ # Create arguments for bps.mv
311
+ for key, signal in self._movable_params.items():
312
+ param_value = value.__dict__[key]
313
+ set_statuses.append(await set_and_wait_for_value(signal, param_value)) # type: ignore
314
+
315
+ # Counter should always start at 0
316
+ set_statuses.append(await set_and_wait_for_value(self.position_counter, 0))
317
+
318
+ LOGGER.info("Gridscan parameters applied, waiting for sets to complete...")
319
+
320
+ # wait for parameter sets to complete
321
+ await asyncio.gather(*set_statuses)
322
+
323
+ LOGGER.info("Sets confirmed, waiting for validity checks to pass...")
324
+ try:
325
+ await wait_for_value(
326
+ self.scan_invalid, 0.0, timeout=self.VALIDITY_CHECK_TIMEOUT
327
+ )
328
+ except TimeoutError as e:
329
+ raise GridScanInvalidException(
330
+ f"Gridscan parameters not validated after {self.VALIDITY_CHECK_TIMEOUT}s"
331
+ ) from e
332
+
333
+ LOGGER.info("Gridscan validity confirmed, gridscan is now prepared.")
334
+
287
335
 
288
336
  class FastGridScanThreeD(FastGridScanCommon[ParamType]):
289
337
  """Device for standard 3D FGS.
@@ -296,23 +344,23 @@ class FastGridScanThreeD(FastGridScanCommon[ParamType]):
296
344
  Subclasses must implement _create_position_counter.
297
345
  """
298
346
 
299
- def __init__(self, prefix: str, name: str = "") -> None:
300
- full_prefix = prefix + "FGS:"
347
+ def __init__(self, prefix: str, infix: str, name: str = "") -> None:
348
+ full_prefix = prefix + infix
301
349
 
302
350
  # Number of vertical steps during the second grid scan, after the rotation in omega
303
- self.z_steps = epics_signal_rw_rbv(int, f"{prefix}Z_NUM_STEPS")
304
- self.z_step_size = epics_signal_rw_rbv(float, f"{prefix}Z_STEP_SIZE")
305
- self.z2_start = epics_signal_rw_rbv(float, f"{prefix}Z2_START")
306
- self.y2_start = epics_signal_rw_rbv(float, f"{prefix}Y2_START")
307
- self.x_counter = epics_signal_r(int, f"{full_prefix}X_COUNTER")
351
+ self.z_steps = epics_signal_rw_rbv(int, f"{full_prefix}Z_NUM_STEPS")
352
+ self.z_step_size = epics_signal_rw_rbv(float, f"{full_prefix}Z_STEP_SIZE")
353
+ self.z2_start = epics_signal_rw_rbv(float, f"{full_prefix}Z2_START")
354
+ self.y2_start = epics_signal_rw_rbv(float, f"{full_prefix}Y2_START")
355
+ # panda does not have x counter
308
356
  self.y_counter = epics_signal_r(int, f"{full_prefix}Y_COUNTER")
309
357
 
310
358
  super().__init__(full_prefix, prefix, name)
311
359
 
312
- self.movable_params["z_step_size_mm"] = self.z_step_size
313
- self.movable_params["z2_start_mm"] = self.z2_start
314
- self.movable_params["y2_start_mm"] = self.y2_start
315
- self.movable_params["z_steps"] = self.z_steps
360
+ self._movable_params["z_step_size_mm"] = self.z_step_size
361
+ self._movable_params["z2_start_mm"] = self.z2_start
362
+ self._movable_params["y2_start_mm"] = self.y2_start
363
+ self._movable_params["z_steps"] = self.z_steps
316
364
 
317
365
  def _create_expected_images_signal(self):
318
366
  return derived_signal_r(
@@ -329,7 +377,35 @@ class FastGridScanThreeD(FastGridScanCommon[ParamType]):
329
377
  return first_grid + second_grid
330
378
 
331
379
  def _create_scan_invalid_signal(self, prefix: str) -> SignalR[float]:
332
- return epics_signal_r(float, f"{prefix}SCAN_INVALID")
380
+ self.x_scan_valid = epics_signal_r(float, f"{prefix}X_SCAN_VALID")
381
+ self.y_scan_valid = epics_signal_r(float, f"{prefix}Y_SCAN_VALID")
382
+ self.z_scan_valid = epics_signal_r(float, f"{prefix}Z_SCAN_VALID")
383
+ self.device_scan_invalid = epics_signal_r(float, f"{prefix}SCAN_INVALID")
384
+
385
+ def compute_derived_value(
386
+ x_scan_valid: float,
387
+ y_scan_valid: float,
388
+ z_scan_valid: float,
389
+ device_scan_invalid: float,
390
+ ) -> float:
391
+ return (
392
+ 1.0
393
+ if not (
394
+ x_scan_valid
395
+ and y_scan_valid
396
+ and z_scan_valid
397
+ and not device_scan_invalid
398
+ )
399
+ else 0.0
400
+ )
401
+
402
+ return derived_signal_r(
403
+ compute_derived_value,
404
+ x_scan_valid=self.x_scan_valid,
405
+ y_scan_valid=self.y_scan_valid,
406
+ z_scan_valid=self.z_scan_valid,
407
+ device_scan_invalid=self.device_scan_invalid,
408
+ )
333
409
 
334
410
  def _create_motion_program(self, motion_controller_prefix: str):
335
411
  return MotionProgram(motion_controller_prefix)
@@ -343,11 +419,13 @@ class ZebraFastGridScanThreeD(FastGridScanThreeD[ZebraGridScanParamsThreeD]):
343
419
  """
344
420
 
345
421
  def __init__(self, prefix: str, name: str = "") -> None:
346
- full_prefix = prefix + "FGS:"
422
+ infix = "FGS:"
423
+ full_prefix = prefix + infix
347
424
  # Time taken to travel between X steps
348
425
  self.dwell_time_ms = epics_signal_rw_rbv(float, f"{full_prefix}DWELL_TIME")
349
- super().__init__(prefix, name)
350
- self.movable_params["dwell_time_ms"] = self.dwell_time_ms
426
+ self.x_counter = epics_signal_r(int, f"{full_prefix}X_COUNTER")
427
+ super().__init__(prefix, infix, name)
428
+ self._movable_params["dwell_time_ms"] = self.dwell_time_ms
351
429
 
352
430
  def _create_position_counter(self, prefix: str):
353
431
  return epics_signal_rw(
@@ -363,7 +441,8 @@ class PandAFastGridScan(FastGridScanThreeD[PandAGridScanParams]):
363
441
  """
364
442
 
365
443
  def __init__(self, prefix: str, name: str = "") -> None:
366
- full_prefix = prefix + "PGS:"
444
+ infix = "PGS:"
445
+ full_prefix = prefix + infix
367
446
  self.time_between_x_steps_ms = (
368
447
  epics_signal_rw_rbv( # Used by motion controller to set goniometer velocity
369
448
  float, f"{full_prefix}TIME_BETWEEN_X_STEPS"
@@ -375,22 +454,22 @@ class PandAFastGridScan(FastGridScanThreeD[PandAGridScanParams]):
375
454
  self.run_up_distance_mm = epics_signal_rw_rbv(
376
455
  float, f"{full_prefix}RUNUP_DISTANCE"
377
456
  )
378
- super().__init__(prefix, name)
457
+ super().__init__(prefix, infix, name)
379
458
 
380
- self.movable_params["run_up_distance_mm"] = self.run_up_distance_mm
459
+ self._movable_params["run_up_distance_mm"] = self.run_up_distance_mm
381
460
 
382
461
  def _create_position_counter(self, prefix: str):
383
462
  return epics_signal_rw(int, f"{prefix}Y_COUNTER")
384
463
 
385
464
 
386
465
  def set_fast_grid_scan_params(scan: FastGridScanCommon[ParamType], params: ParamType):
387
- to_move = []
388
-
389
- # Create arguments for bps.mv
390
- for key in scan.movable_params.keys():
391
- to_move.extend([scan.movable_params[key], params.__dict__[key]])
392
-
393
- # Counter should always start at 0
394
- to_move.extend([scan.position_counter, 0])
466
+ """
467
+ Apply the fast grid scan parameters to the grid scan device and validate them
468
+ Args:
469
+ scan: The fast grid scan device
470
+ params: The parameters to set
395
471
 
396
- yield from mv(*to_move)
472
+ Raises:
473
+ GridScanInvalidException: if the grid scan parameters are not valid
474
+ """
475
+ yield from prepare(scan, params, wait=True)
@@ -0,0 +1,57 @@
1
+ from typing import TypeVar
2
+
3
+ from bluesky.protocols import Movable
4
+ from ophyd_async.core import (
5
+ AsyncStatus,
6
+ EnumTypes,
7
+ StandardReadable,
8
+ )
9
+ from ophyd_async.epics.core import epics_signal_rw
10
+
11
+ StrictEnumT = TypeVar("StrictEnumT", bound=EnumTypes)
12
+
13
+
14
+ class GenericFastShutter(StandardReadable, Movable[StrictEnumT]):
15
+ """
16
+ Basic enum device specialised for a fast shutter with configured open_state and
17
+ close_state so it is generic enough to be used with any device or plan without
18
+ knowing the specific enum to use.
19
+
20
+ For example:
21
+ await shutter.set(shutter.open_state)
22
+ await shutter.set(shutter.close_state)
23
+ OR
24
+ RE(bps.mv(shutter, shutter.open_state))
25
+ RE(bps.mv(shutter, shutter.close_state))
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ prefix: str,
31
+ open_state: StrictEnumT,
32
+ close_state: StrictEnumT,
33
+ name: str = "",
34
+ ):
35
+ """
36
+ Arguments:
37
+ prefix: The prefix for the shutter device.
38
+ open_state: The enum value that corresponds with opening the shutter.
39
+ close_state: The enum value that corresponds with closing the shutter.
40
+ """
41
+ self.open_state = open_state
42
+ self.close_state = close_state
43
+ with self.add_children_as_readables():
44
+ self.state = epics_signal_rw(type(self.open_state), prefix)
45
+ super().__init__(name)
46
+
47
+ @AsyncStatus.wrap
48
+ async def set(self, value: StrictEnumT) -> None:
49
+ await self.state.set(value)
50
+
51
+ async def is_open(self) -> bool:
52
+ """Checks to see if shutter is currently open"""
53
+ return await self.state.get_value() == self.open_state
54
+
55
+ async def is_closed(self) -> bool:
56
+ """Checks to see if shutter is currently closed"""
57
+ return await self.state.get_value() == self.close_state
@@ -35,7 +35,7 @@ class ZebraFastGridScanTwoD(FastGridScanCommon[ZebraGridScanParamsTwoD]):
35
35
  # See https://github.com/DiamondLightSource/mx-bluesky/issues/1203
36
36
  self.dwell_time_ms = epics_signal_rw_rbv(float, f"{full_prefix}EXPOSURE_TIME")
37
37
 
38
- self.movable_params["dwell_time_ms"] = self.dwell_time_ms
38
+ self._movable_params["dwell_time_ms"] = self.dwell_time_ms
39
39
 
40
40
  def _create_expected_images_signal(self):
41
41
  return derived_signal_r(
@@ -32,6 +32,7 @@ class MurkoMetadata(TypedDict):
32
32
  sample_id: str
33
33
  omega_angle: float
34
34
  uuid: str
35
+ used_for_centring: bool | None
35
36
 
36
37
 
37
38
  class Coord(Enum):
@@ -47,6 +48,7 @@ class MurkoResult:
47
48
  y_dist_mm: float
48
49
  omega: float
49
50
  uuid: str
51
+ metadata: MurkoMetadata
50
52
 
51
53
 
52
54
  class NoResultsFound(ValueError):
@@ -101,7 +103,7 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
101
103
 
102
104
  def _reset(self):
103
105
  self._last_omega = 0
104
- self.results: list[MurkoResult] = []
106
+ self._results: list[MurkoResult] = []
105
107
 
106
108
  @AsyncStatus.wrap
107
109
  async def stage(self):
@@ -126,17 +128,17 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
126
128
  continue
127
129
  await self.process_batch(message, sample_id)
128
130
 
129
- if not self.results:
131
+ if not self._results:
130
132
  raise NoResultsFound("No results retrieved from Murko")
131
133
 
132
- for result in self.results:
134
+ for result in self._results:
133
135
  LOGGER.debug(result)
134
136
 
135
- self.filter_outliers()
137
+ filtered_results = self.filter_outliers()
136
138
 
137
- x_dists_mm = [result.x_dist_mm for result in self.results]
138
- y_dists_mm = [result.y_dist_mm for result in self.results]
139
- omegas = [result.omega for result in self.results]
139
+ x_dists_mm = [result.x_dist_mm for result in filtered_results]
140
+ y_dists_mm = [result.y_dist_mm for result in filtered_results]
141
+ omegas = [result.omega for result in filtered_results]
140
142
 
141
143
  LOGGER.info(f"Using average of x beam distances: {x_dists_mm}")
142
144
  avg_x = float(np.mean(x_dists_mm))
@@ -147,6 +149,11 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
147
149
  self._y_mm_setter(-best_y)
148
150
  self._z_mm_setter(-best_z)
149
151
 
152
+ for result in self._results:
153
+ await self.redis_client.hset( # type: ignore
154
+ f"murko:{sample_id}:metadata", result.uuid, json.dumps(result.metadata)
155
+ )
156
+
150
157
  async def process_batch(self, message: dict | None, sample_id: str):
151
158
  if message and message["type"] == "message":
152
159
  batch_results: list[dict] = pickle.loads(message["data"])
@@ -186,13 +193,14 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
186
193
  centre_px[0],
187
194
  centre_px[1],
188
195
  )
189
- self.results.append(
196
+ self._results.append(
190
197
  MurkoResult(
191
198
  centre_px=centre_px,
192
199
  x_dist_mm=beam_dist_px[0] * metadata["microns_per_x_pixel"] / 1000,
193
200
  y_dist_mm=beam_dist_px[1] * metadata["microns_per_y_pixel"] / 1000,
194
201
  omega=omega,
195
202
  uuid=metadata["uuid"],
203
+ metadata=metadata,
196
204
  )
197
205
  )
198
206
  self._last_omega = omega
@@ -203,8 +211,8 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
203
211
  meaning that by keeping only a percentage of the results with the smallest X we
204
212
  remove many of the outliers.
205
213
  """
206
- LOGGER.info(f"Number of results before filtering: {len(self.results)}")
207
- sorted_results = sorted(self.results, key=lambda item: item.centre_px[0])
214
+ LOGGER.info(f"Number of results before filtering: {len(self._results)}")
215
+ sorted_results = sorted(self._results, key=lambda item: item.centre_px[0])
208
216
 
209
217
  worst_results = [
210
218
  r.uuid for r in sorted_results[-self.NUMBER_OF_WRONG_RESULTS_TO_LOG :]
@@ -214,9 +222,13 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
214
222
  f"Worst {self.NUMBER_OF_WRONG_RESULTS_TO_LOG} murko results were {worst_results}"
215
223
  )
216
224
  cutoff = max(1, int(len(sorted_results) * self.PERCENTAGE_TO_USE / 100))
225
+ for i, result in enumerate(sorted_results):
226
+ result.metadata["used_for_centring"] = i < cutoff
227
+
217
228
  smallest_x = sorted_results[:cutoff]
218
- self.results = smallest_x
219
- LOGGER.info(f"Number of results after filtering: {len(self.results)}")
229
+
230
+ LOGGER.info(f"Number of results after filtering: {len(smallest_x)}")
231
+ return smallest_x
220
232
 
221
233
 
222
234
  def get_yz_least_squares(vertical_dists: list, omegas: list) -> tuple[float, float]:
@@ -19,10 +19,9 @@ from ophyd_async.core import (
19
19
  )
20
20
  from pydantic import BaseModel, ConfigDict, RootModel
21
21
 
22
- from dodal.log import LOGGER
23
-
24
- from ..apple2_undulator import (
22
+ from dodal.devices.apple2_undulator import (
25
23
  Apple2,
24
+ Apple2Motors,
26
25
  Apple2Val,
27
26
  EnergyMotorConvertor,
28
27
  Pol,
@@ -30,6 +29,8 @@ from ..apple2_undulator import (
30
29
  UndulatorJawPhase,
31
30
  UndulatorPhaseAxes,
32
31
  )
32
+ from dodal.log import LOGGER
33
+
33
34
  from ..pgm import PGM
34
35
 
35
36
  ROW_PHASE_MOTOR_TOLERANCE = 0.004
@@ -359,14 +360,15 @@ class I10Apple2(Apple2):
359
360
 
360
361
  with self.add_children_as_readables():
361
362
  super().__init__(
362
- id_gap=UndulatorGap(name="id_gap", prefix=prefix),
363
- id_phase=UndulatorPhaseAxes(
364
- name="id_phase",
365
- prefix=prefix,
366
- top_outer="RPQ1",
367
- top_inner="RPQ2",
368
- btm_inner="RPQ3",
369
- btm_outer="RPQ4",
363
+ apple2_motors=Apple2Motors(
364
+ id_gap=UndulatorGap(prefix=prefix),
365
+ id_phase=UndulatorPhaseAxes(
366
+ prefix=prefix,
367
+ top_outer="RPQ1",
368
+ top_inner="RPQ2",
369
+ btm_inner="RPQ3",
370
+ btm_outer="RPQ4",
371
+ ),
370
372
  ),
371
373
  energy_motor_convertor=energy_motor_convertor,
372
374
  name=name,
@@ -376,8 +378,7 @@ class I10Apple2(Apple2):
376
378
  move_pv="RPQ1",
377
379
  )
378
380
 
379
- @AsyncStatus.wrap
380
- async def set(self, value: float) -> None:
381
+ async def _set(self, value: float) -> None:
381
382
  """
382
383
  Check polarisation state and use it together with the energy(value)
383
384
  to calculate the required gap and phases before setting it.
@@ -408,11 +409,10 @@ class I10Apple2(Apple2):
408
409
  )
409
410
 
410
411
  LOGGER.info(f"Setting polarisation to {pol}, with values: {id_set_val}")
411
- await self._set(value=id_set_val, energy=value)
412
+ await self.motors.set(id_motor_values=id_set_val)
412
413
  if pol != Pol.LA:
413
414
  await self.id_jaw_phase.set(0)
414
415
  await self.id_jaw_phase.set_move.set(1)
415
- LOGGER.info(f"Energy set to {value} eV successfully.")
416
416
 
417
417
 
418
418
  class EnergySetter(StandardReadable, Movable[float]):