dls-dodal 1.62.0__py3-none-any.whl → 1.64.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 (92) hide show
  1. {dls_dodal-1.62.0.dist-info → dls_dodal-1.64.0.dist-info}/METADATA +3 -3
  2. {dls_dodal-1.62.0.dist-info → dls_dodal-1.64.0.dist-info}/RECORD +89 -76
  3. dls_dodal-1.64.0.dist-info/entry_points.txt +3 -0
  4. dodal/_version.py +2 -2
  5. dodal/beamlines/__init__.py +1 -0
  6. dodal/beamlines/adsim.py +5 -3
  7. dodal/beamlines/b21.py +3 -1
  8. dodal/beamlines/i02_2.py +32 -0
  9. dodal/beamlines/i03.py +9 -0
  10. dodal/beamlines/i07.py +21 -0
  11. dodal/beamlines/i09.py +11 -4
  12. dodal/beamlines/i09_1.py +10 -4
  13. dodal/beamlines/i09_2.py +30 -0
  14. dodal/beamlines/i10.py +7 -69
  15. dodal/beamlines/i10_1.py +35 -0
  16. dodal/beamlines/i10_optics.py +231 -0
  17. dodal/beamlines/i15_1.py +5 -5
  18. dodal/beamlines/i17.py +60 -1
  19. dodal/beamlines/i18.py +15 -9
  20. dodal/beamlines/i19_1.py +3 -3
  21. dodal/beamlines/i19_2.py +2 -2
  22. dodal/beamlines/i19_optics.py +4 -1
  23. dodal/beamlines/i21.py +31 -1
  24. dodal/beamlines/i24.py +3 -3
  25. dodal/cli.py +7 -7
  26. dodal/common/visit.py +4 -4
  27. dodal/devices/aperturescatterguard.py +6 -4
  28. dodal/devices/apple2_undulator.py +225 -126
  29. dodal/devices/attenuator/filter_selections.py +6 -6
  30. dodal/devices/b07_1/ccmc.py +1 -1
  31. dodal/devices/common_dcm.py +63 -16
  32. dodal/devices/current_amplifiers/femto.py +4 -4
  33. dodal/devices/current_amplifiers/sr570.py +3 -3
  34. dodal/devices/fast_grid_scan.py +4 -4
  35. dodal/devices/fast_shutter.py +19 -7
  36. dodal/devices/i02_2/__init__.py +0 -0
  37. dodal/devices/i03/dcm.py +4 -2
  38. dodal/devices/i03/undulator_dcm.py +1 -1
  39. dodal/devices/i04/murko_results.py +35 -14
  40. dodal/devices/i07/__init__.py +0 -0
  41. dodal/devices/i07/dcm.py +33 -0
  42. dodal/devices/i09/__init__.py +1 -2
  43. dodal/devices/i09_1_shared/__init__.py +3 -0
  44. dodal/devices/i09_1_shared/hard_undulator_functions.py +111 -0
  45. dodal/devices/i10/__init__.py +29 -0
  46. dodal/devices/i10/diagnostics.py +37 -5
  47. dodal/devices/i10/i10_apple2.py +125 -229
  48. dodal/devices/i10/slits.py +38 -6
  49. dodal/devices/i15/dcm.py +7 -46
  50. dodal/devices/i17/__init__.py +0 -0
  51. dodal/devices/i17/i17_apple2.py +51 -0
  52. dodal/devices/i19/access_controlled/__init__.py +0 -0
  53. dodal/devices/i19/{shutter.py → access_controlled/shutter.py} +7 -4
  54. dodal/devices/i22/dcm.py +3 -3
  55. dodal/devices/i24/dcm.py +2 -2
  56. dodal/devices/oav/oav_detector.py +1 -1
  57. dodal/devices/oav/oav_parameters.py +4 -4
  58. dodal/devices/oav/oav_to_redis_forwarder.py +4 -4
  59. dodal/devices/oav/pin_image_recognition/__init__.py +3 -3
  60. dodal/devices/oav/pin_image_recognition/utils.py +1 -1
  61. dodal/devices/oav/snapshots/snapshot.py +1 -1
  62. dodal/devices/oav/snapshots/snapshot_image_processing.py +12 -12
  63. dodal/devices/oav/snapshots/snapshot_with_grid.py +1 -1
  64. dodal/devices/oav/utils.py +2 -2
  65. dodal/devices/pgm.py +3 -3
  66. dodal/devices/robot.py +5 -5
  67. dodal/devices/tetramm.py +9 -5
  68. dodal/devices/thawer.py +0 -4
  69. dodal/devices/util/lookup_tables.py +8 -2
  70. dodal/devices/v2f.py +2 -2
  71. dodal/devices/zebra/zebra_constants_mapping.py +2 -2
  72. dodal/devices/zocalo/__init__.py +4 -4
  73. dodal/devices/zocalo/zocalo_results.py +4 -4
  74. dodal/log.py +9 -9
  75. dodal/plan_stubs/motor_utils.py +4 -4
  76. dodal/plans/configure_arm_trigger_and_disarm_detector.py +2 -2
  77. dodal/plans/save_panda.py +7 -7
  78. dodal/plans/verify_undulator_gap.py +4 -4
  79. dodal/testing/fixtures/__init__.py +0 -0
  80. dodal/testing/fixtures/run_engine.py +46 -0
  81. dodal/testing/fixtures/utils.py +57 -0
  82. dls_dodal-1.62.0.dist-info/entry_points.txt +0 -3
  83. dodal/beamlines/i10-1.py +0 -25
  84. dodal/devices/i09/dcm.py +0 -26
  85. {dls_dodal-1.62.0.dist-info → dls_dodal-1.64.0.dist-info}/WHEEL +0 -0
  86. {dls_dodal-1.62.0.dist-info → dls_dodal-1.64.0.dist-info}/licenses/LICENSE +0 -0
  87. {dls_dodal-1.62.0.dist-info → dls_dodal-1.64.0.dist-info}/top_level.txt +0 -0
  88. /dodal/devices/areadetector/plugins/{CAM.py → cam.py} +0 -0
  89. /dodal/devices/areadetector/plugins/{MJPG.py → mjpg.py} +0 -0
  90. /dodal/devices/i18/{KBMirror.py → kb_mirror.py} +0 -0
  91. /dodal/devices/i19/{blueapi_device.py → access_controlled/blueapi_device.py} +0 -0
  92. /dodal/devices/i19/{hutch_access.py → access_controlled/hutch_access.py} +0 -0
@@ -2,6 +2,7 @@ from typing import Generic, TypeVar
2
2
 
3
3
  from ophyd_async.core import (
4
4
  StandardReadable,
5
+ derived_signal_r,
5
6
  )
6
7
  from ophyd_async.epics.core import epics_signal_r
7
8
  from ophyd_async.epics.motor import Motor
@@ -31,42 +32,39 @@ Xtal_1 = TypeVar("Xtal_1", bound=StationaryCrystal)
31
32
  Xtal_2 = TypeVar("Xtal_2", bound=StationaryCrystal)
32
33
 
33
34
 
34
- class BaseDCM(StandardReadable, Generic[Xtal_1, Xtal_2]):
35
+ class DoubleCrystalMonochromatorBase(StandardReadable, Generic[Xtal_1, Xtal_2]):
35
36
  """
36
- Common device for the double crystal monochromator (DCM), used to select the energy of the beam.
37
+ Base device for the double crystal monochromator (DCM), used to select the energy of the beam.
37
38
 
38
39
  Features common across all DCM's should include virtual motors to set energy/wavelength and contain two crystals,
39
40
  each of which can be movable. Some DCM's contain crystals with roll motors, and some contain crystals with roll and pitch motors.
40
41
  This base device accounts for all combinations of this.
41
42
 
42
- This device should act as a parent for beamline-specific DCM's, in which any other missing signals can be added.
43
+ This device should act as a parent for beamline-specific DCM's which do not match the standard EPICS interface, it provides
44
+ only energy and the crystal configuration. Most beamlines should use DoubleCrystalMonochromator instead
43
45
 
44
46
  Bluesky plans using DCM's should be typed to specify which types of crystals are required. For example, a plan
45
- which only requires one crystal which can roll should be typed 'def my_plan(dcm: BaseDCM[RollCrystal, StationaryCrystal])`
47
+ which only requires one crystal which can roll should be typed
48
+ 'def my_plan(dcm: DoubleCrystalMonochromatorBase[RollCrystal, StationaryCrystal])`
46
49
  """
47
50
 
48
51
  def __init__(
49
52
  self, prefix: str, xtal_1: type[Xtal_1], xtal_2: type[Xtal_2], name: str = ""
50
53
  ) -> None:
51
54
  with self.add_children_as_readables():
52
- # Virtual motor PV's which set the physical motors so that the DCM produces requested
53
- # wavelength/energy
54
- self.energy_in_kev = Motor(prefix + "ENERGY")
55
- self.wavelength_in_a = Motor(prefix + "WAVELENGTH")
56
-
57
- # Real motors
58
- self.bragg_in_degrees = Motor(prefix + "BRAGG")
59
- # Offset ensures that the beam exits the DCM at the same point, regardless of energy.
60
- self.offset_in_mm = Motor(prefix + "OFFSET")
61
-
62
- self.crystal_metadata_d_spacing_a = epics_signal_r(
63
- float, prefix + "DSPACING:RBV"
55
+ # Virtual motor PV's which set the physical motors so that the DCM produces requested energy
56
+ self.energy_in_keV = Motor(prefix + "ENERGY")
57
+ self.energy_in_eV = derived_signal_r(
58
+ self._convert_keV_to_eV, energy_signal=self.energy_in_keV.user_readback
64
59
  )
65
60
 
66
61
  self._make_crystals(prefix, xtal_1, xtal_2)
67
62
 
68
63
  super().__init__(name)
69
64
 
65
+ def _convert_keV_to_eV(self, energy_signal: float) -> float: # noqa: N802
66
+ return energy_signal * 1000
67
+
70
68
  # Prefix convention is different depending on whether there are one or two controllable crystals
71
69
  def _make_crystals(self, prefix: str, xtal_1: type[Xtal_1], xtal_2: type[Xtal_2]):
72
70
  if StationaryCrystal not in [xtal_1, xtal_2]:
@@ -75,3 +73,52 @@ class BaseDCM(StandardReadable, Generic[Xtal_1, Xtal_2]):
75
73
  else:
76
74
  self.xtal_1 = xtal_1(prefix)
77
75
  self.xtal_2 = xtal_2(prefix)
76
+
77
+
78
+ class DoubleCrystalMonochromator(
79
+ DoubleCrystalMonochromatorBase, Generic[Xtal_1, Xtal_2]
80
+ ):
81
+ """
82
+ Common device for the double crystal monochromator (DCM), used to select the energy of the beam.
83
+
84
+ Features common across all DCM's should include virtual motors to set energy/wavelength and contain two crystals,
85
+ each of which can be movable. Some DCM's contain crystals with roll motors, and some contain crystals with roll and pitch motors.
86
+ This base device accounts for all combinations of this.
87
+
88
+ This device should act as a parent for beamline-specific DCM's, in which any other missing signals can be added.
89
+
90
+ Bluesky plans using DCM's should be typed to specify which types of crystals are required. For example, a plan which only
91
+ requires one crystal which can roll should be typed 'def my_plan(dcm: DoubleCrystalMonochromator[RollCrystal, StationaryCrystal])`
92
+ """
93
+
94
+ def __init__(
95
+ self, prefix: str, xtal_1: type[Xtal_1], xtal_2: type[Xtal_2], name: str = ""
96
+ ) -> None:
97
+ super().__init__(prefix, xtal_1, xtal_2, name)
98
+ with self.add_children_as_readables():
99
+ # Virtual motor PV's which set the physical motors so that the DCM produces requested
100
+ # wavelength
101
+ self.wavelength_in_a = Motor(prefix + "WAVELENGTH")
102
+
103
+ # Real motors
104
+ self.bragg_in_degrees = Motor(prefix + "BRAGG")
105
+ # Offset ensures that the beam exits the DCM at the same point, regardless of energy.
106
+ self.offset_in_mm = Motor(prefix + "OFFSET")
107
+
108
+
109
+ class DoubleCrystalMonochromatorWithDSpacing(
110
+ DoubleCrystalMonochromator, Generic[Xtal_1, Xtal_2]
111
+ ):
112
+ """
113
+ Adds crystal D-spacing metadata to the DoubleCrystalMonochromator class. This should be used in preference to the
114
+ DoubleCrystalMonochromator on beamlines which have a "DSPACING:RBV" pv on their DCM.
115
+ """
116
+
117
+ def __init__(
118
+ self, prefix: str, xtal_1: type[Xtal_1], xtal_2: type[Xtal_2], name: str = ""
119
+ ) -> None:
120
+ super().__init__(prefix, xtal_1, xtal_2, name)
121
+ with self.add_children_as_readables():
122
+ self.crystal_metadata_d_spacing_a = epics_signal_r(
123
+ float, prefix + "DSPACING:RBV"
124
+ )
@@ -104,15 +104,15 @@ class FemtoDDPCA(CurrentAmp):
104
104
  + "\n Available gain:"
105
105
  + f" {[f'{c.value:.0e}' for c in self.gain_conversion_table]}"
106
106
  )
107
- SEN_setting = self.gain_conversion_table(value).name
108
- LOGGER.info(f"{self.name} gain change to {SEN_setting}:{value}")
107
+ sensitivity_setting = self.gain_conversion_table(value).name
108
+ LOGGER.info(f"{self.name} gain change to {sensitivity_setting}:{value}")
109
109
 
110
110
  await self.gain.set(
111
- value=self.gain_table[SEN_setting],
111
+ value=self.gain_table[sensitivity_setting],
112
112
  timeout=self.timeout,
113
113
  )
114
114
  # wait for current amplifier's bandpass filter to settle.
115
- await asyncio.sleep(self.raise_timetable[SEN_setting].value)
115
+ await asyncio.sleep(self.raise_timetable[sensitivity_setting].value)
116
116
 
117
117
  async def increase_gain(self, value: int = 1) -> None:
118
118
  current_gain = int((await self.get_gain()).name.split("_")[-1])
@@ -79,7 +79,7 @@ class SR570FullGainTable(Enum):
79
79
 
80
80
 
81
81
  class SR570GainToCurrentTable(float, Enum):
82
- """Conversion table for gain(sen) to current"""
82
+ """Conversion table for gain (sensitivity) to current"""
83
83
 
84
84
  SEN_1 = 1e3
85
85
  SEN_2 = 2e3
@@ -168,10 +168,10 @@ class SR570(CurrentAmp):
168
168
  + "\n Available gain:"
169
169
  + f" {[f'{c.value:.0e}' for c in self.gain_conversion_table]}"
170
170
  )
171
- SEN_setting = self.gain_conversion_table(value).name
171
+ sensitivity_setting = self.gain_conversion_table(value).name
172
172
  LOGGER.info(f"{self.name} gain change to {value}")
173
173
 
174
- coarse_gain, fine_gain = self.combined_table[SEN_setting].value
174
+ coarse_gain, fine_gain = self.combined_table[sensitivity_setting].value
175
175
  await asyncio.gather(
176
176
  self.fine_gain.set(value=fine_gain, timeout=self.timeout),
177
177
  self.coarse_gain.set(value=coarse_gain, timeout=self.timeout),
@@ -31,7 +31,7 @@ from dodal.log import LOGGER
31
31
  from dodal.parameters.experiment_parameter_base import AbstractExperimentWithBeamParams
32
32
 
33
33
 
34
- class GridScanInvalidException(RuntimeError):
34
+ class GridScanInvalidError(RuntimeError):
35
35
  """Raised when the gridscan parameters are not valid."""
36
36
 
37
37
 
@@ -302,7 +302,7 @@ class FastGridScanCommon(
302
302
  value: the gridscan parameters
303
303
 
304
304
  Raises:
305
- GridScanInvalidException: if the gridscan parameters were not valid
305
+ GridScanInvalidError: if the gridscan parameters were not valid
306
306
  """
307
307
  set_statuses = []
308
308
 
@@ -326,7 +326,7 @@ class FastGridScanCommon(
326
326
  self.scan_invalid, 0.0, timeout=self.VALIDITY_CHECK_TIMEOUT
327
327
  )
328
328
  except TimeoutError as e:
329
- raise GridScanInvalidException(
329
+ raise GridScanInvalidError(
330
330
  f"Gridscan parameters not validated after {self.VALIDITY_CHECK_TIMEOUT}s"
331
331
  ) from e
332
332
 
@@ -470,6 +470,6 @@ def set_fast_grid_scan_params(scan: FastGridScanCommon[ParamType], params: Param
470
470
  params: The parameters to set
471
471
 
472
472
  Raises:
473
- GridScanInvalidException: if the grid scan parameters are not valid
473
+ GridScanInvalidError: if the grid scan parameters are not valid
474
474
  """
475
475
  yield from prepare(scan, params, wait=True)
@@ -21,27 +21,27 @@ class GenericFastShutter(StandardReadable, Movable[StrictEnumT]):
21
21
  await shutter.set(shutter.open_state)
22
22
  await shutter.set(shutter.close_state)
23
23
  OR
24
- RE(bps.mv(shutter, shutter.open_state))
25
- RE(bps.mv(shutter, shutter.close_state))
24
+ run_engine(bps.mv(shutter, shutter.open_state))
25
+ run_engine(bps.mv(shutter, shutter.close_state))
26
26
  """
27
27
 
28
28
  def __init__(
29
29
  self,
30
- prefix: str,
30
+ pv: str,
31
31
  open_state: StrictEnumT,
32
32
  close_state: StrictEnumT,
33
33
  name: str = "",
34
34
  ):
35
35
  """
36
36
  Arguments:
37
- prefix: The prefix for the shutter device.
37
+ pv: The pv to connect to the shutter device.
38
38
  open_state: The enum value that corresponds with opening the shutter.
39
39
  close_state: The enum value that corresponds with closing the shutter.
40
40
  """
41
41
  self.open_state = open_state
42
42
  self.close_state = close_state
43
43
  with self.add_children_as_readables():
44
- self.state = epics_signal_rw(type(self.open_state), prefix)
44
+ self.state = epics_signal_rw(type(self.open_state), pv)
45
45
  super().__init__(name)
46
46
 
47
47
  @AsyncStatus.wrap
@@ -49,9 +49,21 @@ class GenericFastShutter(StandardReadable, Movable[StrictEnumT]):
49
49
  await self.state.set(value)
50
50
 
51
51
  async def is_open(self) -> bool:
52
- """Checks to see if shutter is currently open"""
52
+ """
53
+ Checks to see if shutter is in open_state. Should not be used directly inside a
54
+ plan. A user should use the following instead in a plan:
55
+
56
+ from bluesky import plan_stubs as bps
57
+ is_open = yield from bps.rd(shutter.state) == shutter.open_state
58
+ """
53
59
  return await self.state.get_value() == self.open_state
54
60
 
55
61
  async def is_closed(self) -> bool:
56
- """Checks to see if shutter is currently closed"""
62
+ """
63
+ Checks to see if shutter is in close_state. Should not be used directly inside a
64
+ plan. A user should use the following instead in a plan:
65
+
66
+ from bluesky import plan_stubs as bps
67
+ is_closed = yield from bps.rd(shutter.state) == shutter.close_state
68
+ """
57
69
  return await self.state.get_value() == self.close_state
File without changes
dodal/devices/i03/dcm.py CHANGED
@@ -9,13 +9,15 @@ from dodal.common.crystal_metadata import (
9
9
  make_crystal_metadata_from_material,
10
10
  )
11
11
  from dodal.devices.common_dcm import (
12
- BaseDCM,
12
+ DoubleCrystalMonochromatorWithDSpacing,
13
13
  PitchAndRollCrystal,
14
14
  StationaryCrystal,
15
15
  )
16
16
 
17
17
 
18
- class DCM(BaseDCM[PitchAndRollCrystal, StationaryCrystal]):
18
+ class DCM(
19
+ DoubleCrystalMonochromatorWithDSpacing[PitchAndRollCrystal, StationaryCrystal]
20
+ ):
19
21
  """
20
22
  A double crystal monochromator (DCM), used to select the energy of the beam.
21
23
 
@@ -58,7 +58,7 @@ class UndulatorDCM(StandardReadable, Movable[float]):
58
58
  async def set(self, value: float):
59
59
  await self.undulator_ref().raise_if_not_enabled()
60
60
  await asyncio.gather(
61
- self.dcm_ref().energy_in_kev.set(value, timeout=ENERGY_TIMEOUT_S),
61
+ self.dcm_ref().energy_in_keV.set(value, timeout=ENERGY_TIMEOUT_S),
62
62
  self.undulator_ref().set(value),
63
63
  )
64
64
 
@@ -43,7 +43,7 @@ class Coord(Enum):
43
43
 
44
44
  @dataclass
45
45
  class MurkoResult:
46
- centre_px: tuple
46
+ chosen_point_px: tuple[int, int]
47
47
  x_dist_mm: float
48
48
  y_dist_mm: float
49
49
  omega: float
@@ -51,7 +51,7 @@ class MurkoResult:
51
51
  metadata: MurkoMetadata
52
52
 
53
53
 
54
- class NoResultsFound(ValueError):
54
+ class NoResultsFoundError(ValueError):
55
55
  pass
56
56
 
57
57
 
@@ -73,6 +73,7 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
73
73
 
74
74
  TIMEOUT_S = 2
75
75
  PERCENTAGE_TO_USE = 25
76
+ LEFTMOST_PIXEL_TO_USE = 10
76
77
  NUMBER_OF_WRONG_RESULTS_TO_LOG = 5
77
78
 
78
79
  def __init__(
@@ -129,7 +130,7 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
129
130
  await self.process_batch(message, sample_id)
130
131
 
131
132
  if not self._results:
132
- raise NoResultsFound("No results retrieved from Murko")
133
+ raise NoResultsFoundError("No results retrieved from Murko")
133
134
 
134
135
  for result in self._results:
135
136
  LOGGER.debug(result)
@@ -186,16 +187,16 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
186
187
  else:
187
188
  shape = result["original_shape"] # Dimensions of image in pixels
188
189
  # Murko returns coords as y, x
189
- centre_px = (coords[1] * shape[1], coords[0] * shape[0])
190
+ chosen_point_px = (coords[1] * shape[1], coords[0] * shape[0])
190
191
 
191
192
  beam_dist_px = calculate_beam_distance(
192
193
  (metadata["beam_centre_i"], metadata["beam_centre_j"]),
193
- centre_px[0],
194
- centre_px[1],
194
+ chosen_point_px[0],
195
+ chosen_point_px[1],
195
196
  )
196
197
  self._results.append(
197
198
  MurkoResult(
198
- centre_px=centre_px,
199
+ chosen_point_px=chosen_point_px,
199
200
  x_dist_mm=beam_dist_px[0] * metadata["microns_per_x_pixel"] / 1000,
200
201
  y_dist_mm=beam_dist_px[1] * metadata["microns_per_y_pixel"] / 1000,
201
202
  omega=omega,
@@ -209,10 +210,27 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
209
210
  """Whilst murko is not fully trained it often gives us poor results.
210
211
  When it is wrong it usually picks up the base of the pin, rather than the tip,
211
212
  meaning that by keeping only a percentage of the results with the smallest X we
212
- remove many of the outliers.
213
+ remove many of the outliers. Murko also occasionally picks a point in the bottom
214
+ left corner, which can be removed by filtering results with a small x pixel.
213
215
  """
216
+
214
217
  LOGGER.info(f"Number of results before filtering: {len(self._results)}")
215
- sorted_results = sorted(self._results, key=lambda item: item.centre_px[0])
218
+ sorted_results = sorted(self._results, key=lambda item: item.chosen_point_px[0])
219
+
220
+ results_without_tiny_x = [
221
+ result
222
+ for result in sorted_results
223
+ if result.chosen_point_px[0] >= self.LEFTMOST_PIXEL_TO_USE
224
+ ]
225
+ result_uuids_with_tiny_x = [
226
+ result.uuid
227
+ for result in sorted_results
228
+ if result not in results_without_tiny_x
229
+ ]
230
+
231
+ LOGGER.info(
232
+ f"Results with tiny x have been removed: {result_uuids_with_tiny_x}"
233
+ )
216
234
 
217
235
  worst_results = [
218
236
  r.uuid for r in sorted_results[-self.NUMBER_OF_WRONG_RESULTS_TO_LOG :]
@@ -221,14 +239,17 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
221
239
  LOGGER.info(
222
240
  f"Worst {self.NUMBER_OF_WRONG_RESULTS_TO_LOG} murko results were {worst_results}"
223
241
  )
242
+
224
243
  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
244
+ cutoff = min(cutoff, len(results_without_tiny_x))
245
+
246
+ best_x = results_without_tiny_x[:cutoff]
227
247
 
228
- smallest_x = sorted_results[:cutoff]
248
+ for result in sorted_results:
249
+ result.metadata["used_for_centring"] = result in best_x
229
250
 
230
- LOGGER.info(f"Number of results after filtering: {len(smallest_x)}")
231
- return smallest_x
251
+ LOGGER.info(f"Number of results after filtering: {len(best_x)}")
252
+ return best_x
232
253
 
233
254
 
234
255
  def get_yz_least_squares(vertical_dists: list, omegas: list) -> tuple[float, float]:
File without changes
@@ -0,0 +1,33 @@
1
+ from ophyd_async.epics.core import epics_signal_r
2
+ from ophyd_async.epics.motor import Motor
3
+
4
+ from dodal.devices.common_dcm import (
5
+ DoubleCrystalMonochromator,
6
+ PitchAndRollCrystal,
7
+ StationaryCrystal,
8
+ )
9
+
10
+
11
+ class DCM(DoubleCrystalMonochromator[PitchAndRollCrystal, StationaryCrystal]):
12
+ """
13
+ Device for i07's DCM, including temperature monitors and vertical motor which were
14
+ included in GDA.
15
+ """
16
+
17
+ def __init__(
18
+ self,
19
+ motor_prefix: str,
20
+ xtal_prefix: str,
21
+ name: str = "",
22
+ ) -> None:
23
+ super().__init__(motor_prefix, PitchAndRollCrystal, StationaryCrystal, name)
24
+ with self.add_children_as_readables():
25
+ self.vertical_in_mm = Motor(motor_prefix + "PERP")
26
+
27
+ # temperatures
28
+ self.xtal1_temp = epics_signal_r(float, xtal_prefix + "PT100-2")
29
+ self.xtal2_temp = epics_signal_r(float, xtal_prefix + "PT100-3")
30
+ self.xtal1_holder_temp = epics_signal_r(float, xtal_prefix + "PT100-1")
31
+ self.xtal2_holder_temp = epics_signal_r(float, xtal_prefix + "PT100-4")
32
+ self.gap_motor = epics_signal_r(float, xtal_prefix + "TC-1")
33
+ self.white_beam_stop_temp = epics_signal_r(float, xtal_prefix + "WBS:TEMP")
@@ -1,4 +1,3 @@
1
- from dodal.devices.i09.dcm import DCM
2
1
  from dodal.devices.i09.enums import Grating, LensMode, PassEnergy, PsuMode
3
2
 
4
- __all__ = ["DCM", "Grating", "LensMode", "PsuMode", "PassEnergy"]
3
+ __all__ = ["Grating", "LensMode", "PsuMode", "PassEnergy"]
@@ -0,0 +1,3 @@
1
+ from .hard_undulator_functions import calculate_gap_i09_hu, get_hu_lut_as_dict
2
+
3
+ __all__ = ["calculate_gap_i09_hu", "get_hu_lut_as_dict"]
@@ -0,0 +1,111 @@
1
+ import numpy as np
2
+
3
+ from dodal.devices.util.lookup_tables import energy_distance_table
4
+ from dodal.log import LOGGER
5
+
6
+ LUT_COMMENTS = ["#"]
7
+ HU_SKIP_ROWS = 3
8
+
9
+ # Physics constants
10
+ ELECTRON_REST_ENERGY_MEV = 0.510999
11
+
12
+ # Columns in the lookup table
13
+ RING_ENERGY_COLUMN = 1
14
+ MAGNET_FIELD_COLUMN = 2
15
+ MIN_ENERGY_COLUMN = 3
16
+ MAX_ENERGY_COLUMN = 4
17
+ GAP_OFFSET_COLUMN = 7
18
+
19
+
20
+ async def get_hu_lut_as_dict(lut_path: str) -> dict:
21
+ lut_dict: dict = {}
22
+ _lookup_table: np.ndarray = await energy_distance_table(
23
+ lut_path,
24
+ comments=LUT_COMMENTS,
25
+ skiprows=HU_SKIP_ROWS,
26
+ )
27
+ for i in range(_lookup_table.shape[0]):
28
+ lut_dict[_lookup_table[i][0]] = _lookup_table[i]
29
+ LOGGER.debug(f"Loaded lookup table:\n {lut_dict}")
30
+ return lut_dict
31
+
32
+
33
+ def calculate_gap_i09_hu(
34
+ photon_energy_kev: float,
35
+ look_up_table: dict[int, "np.ndarray"],
36
+ order: int = 1,
37
+ gap_offset: float = 0.0,
38
+ undulator_period_mm: int = 27,
39
+ ) -> float:
40
+ """
41
+ Calculate the undulator gap required to produce a given energy at a given harmonic order.
42
+ This algorithm was provided by the I09 beamline scientists, and is based on the physics of undulator radiation.
43
+ https://cxro.lbl.gov//PDF/X-Ray-Data-Booklet.pdf
44
+
45
+ Args:
46
+ photon_energy_kev (float): Requested photon energy in keV.
47
+ look_up_table (dict[int, np.ndarray]): Lookup table containing undulator and beamline parameters for each harmonic order.
48
+ order (int, optional): Harmonic order for which to calculate the gap. Defaults to 1.
49
+ gap_offset (float, optional): Additional gap offset to apply (in mm). Defaults to 0.0.
50
+ undulator_period_mm (int, optional): Undulator period in mm. Defaults to 27.
51
+
52
+ Returns:
53
+ float: Calculated undulator gap in millimeters.
54
+ """
55
+ magnet_blocks_per_period = 4
56
+ magnet_block_height_mm = 16
57
+
58
+ if order not in look_up_table.keys():
59
+ raise ValueError(f"Order parameter {order} not found in lookup table")
60
+
61
+ gamma = 1000 * look_up_table[order][RING_ENERGY_COLUMN] / ELECTRON_REST_ENERGY_MEV
62
+
63
+ # Constructive interference of radiation emitted at different poles
64
+ # lamda = (lambda_u/2*gamma^2)*(1+K^2/2 + gamma^2*theta^2)/n for n=1,2,3...
65
+ # theta is the observation angle, assumed to be 0 here.
66
+ # Rearranging for K (the undulator parameter, related to magnetic field and gap)
67
+ # gives K^2 = 2*((2*n*gamma^2*lamda/lambda_u)-1)
68
+
69
+ undulator_parameter_sqr = (
70
+ 4.959368e-6
71
+ * (order * gamma * gamma / (undulator_period_mm * photon_energy_kev))
72
+ - 2
73
+ )
74
+ if undulator_parameter_sqr < 0:
75
+ raise ValueError(
76
+ f"Diffraction parameter squared must be positive! Calculated value {undulator_parameter_sqr}."
77
+ )
78
+ undulator_parameter = np.sqrt(undulator_parameter_sqr)
79
+
80
+ # Undulator_parameter K is also defined as K = 0.934*B0[T]*lambda_u[cm],
81
+ # where B0[T] is a peak magnetic field that must depend on gap,
82
+ # but in our LUT it is does not depend on gap, so it's a factor,
83
+ # leading to K = 0.934*B0[T]*lambda_u[cm]*exp(-pi*gap/lambda_u) or
84
+ # K = undulator_parameter_max*exp(-pi*gap/lambda_u)
85
+ # Calculating undulator_parameter_max gives:
86
+ undulator_parameter_max = (
87
+ (
88
+ 2
89
+ * 0.0934
90
+ * undulator_period_mm
91
+ * look_up_table[order][MAGNET_FIELD_COLUMN]
92
+ * magnet_blocks_per_period
93
+ / np.pi
94
+ )
95
+ * np.sin(np.pi / magnet_blocks_per_period)
96
+ * (1 - np.exp(-2 * np.pi * magnet_block_height_mm / undulator_period_mm))
97
+ )
98
+
99
+ # Finnaly, rearranging the equation:
100
+ # undulator_parameter = undulator_parameter_max*exp(-pi*gap/lambda_u) for gap gives
101
+ gap = (
102
+ (undulator_period_mm / np.pi)
103
+ * np.log(undulator_parameter_max / undulator_parameter)
104
+ + look_up_table[order][GAP_OFFSET_COLUMN]
105
+ + gap_offset
106
+ )
107
+ LOGGER.debug(
108
+ f"Calculated gap is {gap}mm for energy {photon_energy_kev}keV at order {order}"
109
+ )
110
+
111
+ return gap
@@ -0,0 +1,29 @@
1
+ from .diagnostics import (
2
+ I10Diagnostic,
3
+ I10Diagnostic5ADet,
4
+ I10JDiagnostic,
5
+ I10PneumaticStage,
6
+ I10SharedDiagnostic,
7
+ )
8
+ from .mirrors import PiezoMirror
9
+ from .slits import (
10
+ I10JSlits,
11
+ I10SharedSlits,
12
+ I10SharedSlitsDrainCurrent,
13
+ I10Slits,
14
+ I10SlitsDrainCurrent,
15
+ )
16
+
17
+ __all__ = [
18
+ "I10Diagnostic",
19
+ "I10Diagnostic5ADet",
20
+ "I10PneumaticStage",
21
+ "PiezoMirror",
22
+ "I10Slits",
23
+ "I10SlitsDrainCurrent",
24
+ "I10SharedDiagnostic",
25
+ "I10SharedSlits",
26
+ "I10JSlits",
27
+ "I10SharedSlitsDrainCurrent",
28
+ "I10JDiagnostic",
29
+ ]
@@ -28,7 +28,7 @@ class D3Position(StrictEnum):
28
28
  GRID = "Grid"
29
29
 
30
30
 
31
- class D5Position(StrictEnum):
31
+ class CellPosition(StrictEnum):
32
32
  CELL_IN = "Cell In"
33
33
  CELL_OUT = "Cell Out"
34
34
 
@@ -68,6 +68,21 @@ class InStateReadBackTable(StrictEnum):
68
68
  OUT_OF_BEAM = "Out of Beam"
69
69
 
70
70
 
71
+ class D2jPosition(StrictEnum):
72
+ OUT_OF_THE_BEAM = "Out of the beam"
73
+ DIODE = "Diode"
74
+ BLADE = "Blade"
75
+ LA = "La ref"
76
+ GD = "Gd ref"
77
+ YB = "Yb ref"
78
+
79
+
80
+ class D3jPosition(StrictEnum):
81
+ OUT_OF_THE_BEAM = "Out of the beam"
82
+ DIODE_IN = "Diode In"
83
+ DIAMOND_WINDOW = "Diamond window"
84
+
85
+
71
86
  class I10PneumaticStage(StandardReadable):
72
87
  """Pneumatic stage only has two real positions in or out.
73
88
  Use for fluorescent screen which can be insert into the x-ray beam.
@@ -138,9 +153,7 @@ class FullDiagnostic(Device):
138
153
  super().__init__(name)
139
154
 
140
155
 
141
- class I10Diagnostic(Device):
142
- """Collection of all the diagnostic stage on i10."""
143
-
156
+ class I10SharedDiagnostic(Device):
144
157
  def __init__(self, prefix, name: str = "") -> None:
145
158
  self.d1 = ScreenCam(prefix=prefix + "PHDGN-01:")
146
159
  self.d2 = ScreenCam(prefix=prefix + "PHDGN-02:")
@@ -149,8 +162,15 @@ class I10Diagnostic(Device):
149
162
  positioner_enum=D3Position,
150
163
  positioner_suffix="DET:X",
151
164
  )
165
+ super().__init__(name)
166
+
167
+
168
+ class I10Diagnostic(Device):
169
+ """Collection of all the diagnostic stage on i10."""
170
+
171
+ def __init__(self, prefix, name: str = "") -> None:
152
172
  self.d4 = ScreenCam(prefix=prefix + "PHDGN-04:")
153
- self.d5 = create_positioner(D5Position, f"{prefix}IONC-01:Y")
173
+ self.d5 = create_positioner(CellPosition, f"{prefix}IONC-01:Y")
154
174
  self.d5A = create_positioner(D5APosition, f"{prefix}PHDGN-06:DET:X")
155
175
  self.d6 = FullDiagnostic(f"{prefix}PHDGN-05:", D6Position, "DET:X")
156
176
  self.d7 = create_positioner(D7Position, f"{prefix}PHDGN-07:Y")
@@ -158,6 +178,18 @@ class I10Diagnostic(Device):
158
178
  super().__init__(name)
159
179
 
160
180
 
181
+ class I10JDiagnostic(Device):
182
+ """Collection of all the diagnostic stage on i10-1."""
183
+
184
+ def __init__(self, prefix, name: str = "") -> None:
185
+ self.dj1 = ScreenCam(prefix=prefix + "PHDGN-01:")
186
+ self.dj2 = create_positioner(CellPosition, f"{prefix}IONC-01:Y")
187
+ self.dj2A = create_positioner(D2jPosition, f"{prefix}PHDGN-03:DET:X")
188
+ self.dj3 = FullDiagnostic(f"{prefix}PHDGN-02:", D3jPosition, "DET:X")
189
+
190
+ super().__init__(name)
191
+
192
+
161
193
  class I10Diagnostic5ADet(Device):
162
194
  """Diagnostic 5a detection with drain current and photo diode"""
163
195