dls-dodal 1.65.0__py3-none-any.whl → 1.67.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/METADATA +3 -4
  2. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/RECORD +82 -66
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/aithre.py +21 -2
  5. dodal/beamlines/i03.py +102 -198
  6. dodal/beamlines/i04.py +40 -4
  7. dodal/beamlines/i05.py +28 -1
  8. dodal/beamlines/i06.py +62 -0
  9. dodal/beamlines/i07.py +20 -0
  10. dodal/beamlines/i09_1.py +32 -3
  11. dodal/beamlines/i09_2.py +57 -2
  12. dodal/beamlines/i10_optics.py +46 -17
  13. dodal/beamlines/i17.py +7 -3
  14. dodal/beamlines/i18.py +3 -3
  15. dodal/beamlines/i19_1.py +26 -14
  16. dodal/beamlines/i19_2.py +49 -38
  17. dodal/beamlines/i21.py +2 -2
  18. dodal/beamlines/i22.py +19 -4
  19. dodal/beamlines/p38.py +3 -3
  20. dodal/beamlines/training_rig.py +0 -16
  21. dodal/cli.py +26 -12
  22. dodal/common/coordination.py +3 -2
  23. dodal/device_manager.py +604 -0
  24. dodal/devices/aithre_lasershaping/goniometer.py +26 -9
  25. dodal/devices/aperturescatterguard.py +3 -2
  26. dodal/devices/areadetector/plugins/mjpg.py +10 -3
  27. dodal/devices/beamsize/__init__.py +0 -0
  28. dodal/devices/beamsize/beamsize.py +6 -0
  29. dodal/devices/cryostream.py +28 -57
  30. dodal/devices/detector/det_resolution.py +4 -2
  31. dodal/devices/eiger.py +26 -18
  32. dodal/devices/fast_grid_scan.py +14 -2
  33. dodal/devices/i03/beamsize.py +35 -0
  34. dodal/devices/i03/constants.py +7 -0
  35. dodal/devices/i03/undulator_dcm.py +2 -2
  36. dodal/devices/i04/beamsize.py +45 -0
  37. dodal/devices/i04/max_pixel.py +38 -0
  38. dodal/devices/i04/murko_results.py +36 -26
  39. dodal/devices/i04/transfocator.py +23 -29
  40. dodal/devices/i07/id.py +38 -0
  41. dodal/devices/i09_1_shared/__init__.py +13 -2
  42. dodal/devices/i09_1_shared/hard_energy.py +112 -0
  43. dodal/devices/i09_1_shared/hard_undulator_functions.py +85 -21
  44. dodal/devices/i09_2_shared/__init__.py +0 -0
  45. dodal/devices/i09_2_shared/i09_apple2.py +86 -0
  46. dodal/devices/i10/i10_apple2.py +39 -331
  47. dodal/devices/i17/i17_apple2.py +37 -22
  48. dodal/devices/i19/access_controlled/attenuator_motor_squad.py +61 -0
  49. dodal/devices/i19/access_controlled/blueapi_device.py +9 -1
  50. dodal/devices/i19/access_controlled/shutter.py +2 -4
  51. dodal/devices/insertion_device/__init__.py +0 -0
  52. dodal/devices/{apple2_undulator.py → insertion_device/apple2_undulator.py} +122 -69
  53. dodal/devices/insertion_device/energy_motor_lookup.py +88 -0
  54. dodal/devices/insertion_device/lookup_table_models.py +287 -0
  55. dodal/devices/ipin.py +20 -2
  56. dodal/devices/motors.py +33 -3
  57. dodal/devices/mx_phase1/beamstop.py +31 -12
  58. dodal/devices/oav/oav_calculations.py +9 -4
  59. dodal/devices/oav/oav_detector.py +65 -7
  60. dodal/devices/oav/oav_parameters.py +3 -1
  61. dodal/devices/oav/oav_to_redis_forwarder.py +18 -15
  62. dodal/devices/oav/pin_image_recognition/__init__.py +5 -1
  63. dodal/devices/oav/pin_image_recognition/utils.py +23 -1
  64. dodal/devices/oav/snapshots/snapshot_with_grid.py +8 -2
  65. dodal/devices/oav/utils.py +16 -6
  66. dodal/devices/robot.py +33 -18
  67. dodal/devices/scintillator.py +36 -14
  68. dodal/devices/smargon.py +2 -3
  69. dodal/devices/thawer.py +7 -45
  70. dodal/devices/undulator.py +152 -68
  71. dodal/plans/__init__.py +1 -1
  72. dodal/plans/configure_arm_trigger_and_disarm_detector.py +2 -4
  73. dodal/plans/load_panda_yaml.py +9 -0
  74. dodal/plans/verify_undulator_gap.py +2 -2
  75. dodal/testing/fixtures/devices/__init__.py +0 -0
  76. dodal/testing/fixtures/devices/apple2.py +78 -0
  77. dodal/utils.py +6 -3
  78. dodal/beamline_specific_utils/i03.py +0 -17
  79. dodal/testing/__init__.py +0 -3
  80. dodal/testing/setup.py +0 -67
  81. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/WHEEL +0 -0
  82. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/entry_points.txt +0 -0
  83. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/licenses/LICENSE +0 -0
  84. {dls_dodal-1.65.0.dist-info → dls_dodal-1.67.0.dist-info}/top_level.txt +0 -0
  85. /dodal/plans/{scanspec.py → spec_path.py} +0 -0
@@ -0,0 +1,6 @@
1
+ from ophyd_async.core import SignalR, StandardReadable
2
+
3
+
4
+ class BeamsizeBase(StandardReadable):
5
+ x_um: SignalR[float]
6
+ y_um: SignalR[float]
@@ -1,5 +1,4 @@
1
1
  from ophyd_async.core import (
2
- EnabledDisabled,
3
2
  InOut,
4
3
  StandardReadable,
5
4
  StandardReadableFormat,
@@ -12,20 +11,6 @@ from ophyd_async.epics.core import (
12
11
  )
13
12
 
14
13
 
15
- class CryoStream(StandardReadable):
16
- MAX_TEMP_K = 110
17
- MAX_PRESSURE_BAR = 0.1
18
-
19
- def __init__(self, prefix: str, name: str = ""):
20
- self.course = epics_signal_rw(InOut, f"{prefix}-EA-CJET-01:COARSE:CTRL")
21
- self.fine = epics_signal_rw(InOut, f"{prefix}-EA-CJET-01:FINE:CTRL")
22
- self.temperature_k = epics_signal_r(float, f"{prefix}-EA-CSTRM-01:TEMP")
23
- self.back_pressure_bar = epics_signal_r(
24
- float, f"{prefix}-EA-CSTRM-01:BACKPRESS"
25
- )
26
- super().__init__(name)
27
-
28
-
29
14
  class TurboEnum(StrictEnum):
30
15
  OFF = "Off"
31
16
  ON = "On"
@@ -37,59 +22,33 @@ class CryoStreamSelection(StrictEnum):
37
22
  HC1 = "HC1"
38
23
 
39
24
 
40
- class OxfordCryoStreamController(StandardReadable):
25
+ class OxfordCryoStream(StandardReadable):
41
26
  def __init__(self, prefix: str, name: str = ""):
27
+ with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
28
+ self.temp = epics_signal_r(float, f"{prefix}TEMP")
29
+
42
30
  with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
43
31
  # Any signals that should be read once at the start of the scan
32
+
44
33
  self.turbo = epics_signal_rw(str, f"{prefix}TURBO")
45
34
  self.turbo_mode = epics_signal_rw(TurboEnum, f"{prefix}TURBOMODE")
46
-
47
- self.serial_comms = epics_signal_rw(EnabledDisabled, f"{prefix}DISABLE")
48
35
  self.status = epics_signal_r(str, f"{prefix}STATUS.SEVR")
49
-
50
- with self.add_children_as_readables():
51
- # Any signals that should be read at every point in the scan
52
-
53
- self.purge = epics_signal_x(f"{prefix}PURGE.PROC")
54
- self.hold = epics_signal_x(f"{prefix}HOLD.PROC")
55
- self.start = epics_signal_x(f"{prefix}RESTART.PROC")
56
- self.pause = epics_signal_x(f"{prefix}PAUSE.PROC")
57
- self.resume = epics_signal_x(f"{prefix}RESUME.PROC")
58
- self.end = epics_signal_x(f"{prefix}END.PROC")
59
- self.stop = epics_signal_x(f"{prefix}STOP.PROC")
60
-
61
- self.ramp_rate = epics_signal_rw(float, f"{prefix}RRATE")
62
- self.ramp_temp = epics_signal_rw(float, f"{prefix}RTEMP")
63
- self.ramp = epics_signal_x(f"{prefix}RAMP.PROC")
64
-
65
- self.plat_time = epics_signal_rw(float, f"{prefix}PTIME")
66
- self.plat = epics_signal_x(f"{prefix}PLAT.PROC")
67
-
68
- self.cool_temp = epics_signal_rw(float, f"{prefix}CTEMP")
69
- self.cool = epics_signal_x(f"{prefix}COOL.PROC")
70
-
71
- self.end_rate = epics_signal_rw(float, f"{prefix}ERATE")
72
-
73
- super().__init__(name)
74
-
75
-
76
- class OxfordCryoStreamStatus(StandardReadable):
77
- def __init__(self, prefix: str, name: str = ""):
78
- with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
79
- # Any signals that should be read once at the start of the scan
80
-
81
36
  self.pump_uptime = epics_signal_r(float, f"{prefix}RUNTIME")
82
37
  self.controller_number = epics_signal_r(float, f"{prefix}CTRLNUM")
83
38
  self.software_version = epics_signal_r(float, f"{prefix}VER")
84
39
  self.evap_adjust = epics_signal_r(float, f"{prefix}EVAPADJUST")
85
40
  self.series = epics_signal_r(str, f"{prefix}SERIES")
41
+ self.error = epics_signal_r(float, f"{prefix}ERROR")
42
+ self.mode = epics_signal_r(str, f"{prefix}RUNMODE")
86
43
 
87
44
  with self.add_children_as_readables():
88
45
  # Any signals that should be read at every point in the scan
46
+ self.ramp_rate = epics_signal_rw(float, f"{prefix}RRATE")
47
+ self.ramp_temp = epics_signal_rw(float, f"{prefix}RTEMP")
48
+ self.plat_time = epics_signal_rw(float, f"{prefix}PTIME")
49
+ self.cool_temp = epics_signal_rw(float, f"{prefix}CTEMP")
50
+ self.end_rate = epics_signal_rw(float, f"{prefix}ERATE")
89
51
  self.setpoint = epics_signal_r(float, f"{prefix}SETPOINT")
90
- self.temp = epics_signal_r(float, f"{prefix}TEMP")
91
- self.error = epics_signal_r(float, f"{prefix}ERROR")
92
- self.mode = epics_signal_r(str, f"{prefix}RUNMODE")
93
52
  self.phase = epics_signal_r(str, f"{prefix}PHASE")
94
53
  self.ramp_rate_setpoint = epics_signal_r(float, f"{prefix}RAMPRATE")
95
54
  self.target_temp = epics_signal_r(float, f"{prefix}TARGETTEMP")
@@ -102,14 +61,27 @@ class OxfordCryoStreamStatus(StandardReadable):
102
61
  self.suct_heat = epics_signal_r(float, f"{prefix}SUCTHEAT")
103
62
  self.back_pressure = epics_signal_r(float, f"{prefix}BACKPRESS")
104
63
 
64
+ self.purge = epics_signal_x(f"{prefix}PURGE.PROC")
65
+ self.hold = epics_signal_x(f"{prefix}HOLD.PROC")
66
+ self.start = epics_signal_x(f"{prefix}RESTART.PROC")
67
+ self.pause = epics_signal_x(f"{prefix}PAUSE.PROC")
68
+ self.resume = epics_signal_x(f"{prefix}RESUME.PROC")
69
+ self.end = epics_signal_x(f"{prefix}END.PROC")
70
+ self.stop = epics_signal_x(f"{prefix}STOP.PROC")
71
+ self.plat = epics_signal_x(f"{prefix}PLAT.PROC")
72
+ self.cool = epics_signal_x(f"{prefix}COOL.PROC")
73
+ self.ramp = epics_signal_x(f"{prefix}RAMP.PROC")
74
+
105
75
  super().__init__(name)
106
76
 
107
77
 
108
- class OxfordCryoStream(StandardReadable):
78
+ class OxfordCryoJet(StandardReadable):
79
+ # TODO: https://github.com/DiamondLightSource/dodal/issues/1486
80
+ # This is a placeholder implementation to get it working with I03, the actual cryojet has many more PVs
109
81
  def __init__(self, prefix: str, name=""):
110
82
  with self.add_children_as_readables():
111
- self.controller = OxfordCryoStreamController(prefix=prefix)
112
- self.status = OxfordCryoStreamStatus(prefix=prefix)
83
+ self.coarse = epics_signal_rw(InOut, f"{prefix}COARSE:CTRL")
84
+ self.fine = epics_signal_rw(InOut, f"{prefix}FINE:CTRL")
113
85
 
114
86
  super().__init__(name)
115
87
 
@@ -126,5 +98,4 @@ class CryoStreamGantry(StandardReadable):
126
98
  self.cryostream_selected = epics_signal_r(
127
99
  int, f"{prefix}-MO-STEP-02:GPIO_INP_BITS.B3"
128
100
  )
129
-
130
101
  super().__init__(name)
@@ -10,14 +10,16 @@ def _get_detector_radius_mm(detector_params: DetectorParams):
10
10
  return 0.5 * _get_detector_max_size_mm(detector_params)
11
11
 
12
12
 
13
- def _get_detector_max_size_mm(detector_params):
13
+ def _get_detector_max_size_mm(detector_params: DetectorParams):
14
14
  return max(
15
15
  detector_params.detector_size_constants.det_dimension.width,
16
16
  detector_params.detector_size_constants.det_dimension.height,
17
17
  )
18
18
 
19
19
 
20
- def _get_beam_xy_accounting_for_roi(detector_params, det_distance_mm):
20
+ def _get_beam_xy_accounting_for_roi(
21
+ detector_params: DetectorParams, det_distance_mm: float
22
+ ):
21
23
  beam_x = detector_params.beam_xy_converter.get_beam_xy_from_det_dist(
22
24
  det_distance_mm, Axis.X_AXIS
23
25
  )
dodal/devices/eiger.py CHANGED
@@ -64,14 +64,12 @@ class EigerDetector(Device, Stageable):
64
64
  arming_status = Status()
65
65
  arming_status.set_finished()
66
66
 
67
- disarming_status = Status()
68
- disarming_status.set_finished()
69
-
70
67
  def __init__(self, beamline: str = "i03", *args, **kwargs):
71
68
  super().__init__(*args, **kwargs)
72
69
  self.beamline = beamline
73
70
  # using i03 timeouts as default
74
71
  self.timeouts = AVAILABLE_TIMEOUTS.get(beamline, AVAILABLE_TIMEOUTS["i03"])
72
+ self.disarming_status = None
75
73
 
76
74
  @classmethod
77
75
  def with_params(
@@ -106,6 +104,7 @@ class EigerDetector(Device, Stageable):
106
104
  raise Exception("\n".join(errors))
107
105
 
108
106
  def async_stage(self):
107
+ self.disarming_status = None
109
108
  self.odin.nodes.clear_odin_errors()
110
109
  status_ok, error_message = self.odin.wait_for_odin_initialised(
111
110
  self.timeouts.general_status_timeout
@@ -170,22 +169,31 @@ class EigerDetector(Device, Stageable):
170
169
  def stop(self, *args):
171
170
  """Emergency stop the device, mainly used to clean up after error."""
172
171
  LOGGER.info("Eiger stop() called - cleaning up...")
173
- if not self.disarming_status.done:
172
+ if self.disarming_status and not self.disarming_status.done:
174
173
  LOGGER.info("Eiger still disarming, waiting on disarm")
175
174
  self.disarming_status.wait(self.timeouts.arming_timeout)
175
+ elif not self.disarming_status:
176
+ self.disarming_status = Status()
177
+ try:
178
+ self.wait_on_arming_if_started()
179
+ stop_status = self.odin.stop()
180
+ self.odin.file_writer.start_timeout.set(1).wait(
181
+ self.timeouts.general_status_timeout
182
+ )
183
+ self.disarm_detector()
184
+ stop_status &= self.disable_roi_mode()
185
+ LOGGER.info("Waiting on stop status")
186
+ stop_status.wait(self.timeouts.general_status_timeout)
187
+ # See https://github.com/DiamondLightSource/hyperion/issues/1395
188
+ LOGGER.info("Turning off Eiger dev/shm streaming")
189
+ self.odin.fan.dev_shm_enable.set(0).wait(
190
+ self.timeouts.general_status_timeout
191
+ )
192
+ LOGGER.info("Eiger has successfully been stopped")
193
+ finally:
194
+ self.disarming_status.set_finished()
176
195
  else:
177
- self.wait_on_arming_if_started()
178
- stop_status = self.odin.stop()
179
- self.odin.file_writer.start_timeout.set(1).wait(
180
- self.timeouts.general_status_timeout
181
- )
182
- self.disarm_detector()
183
- stop_status &= self.disable_roi_mode()
184
- stop_status.wait(self.timeouts.general_status_timeout)
185
- # See https://github.com/DiamondLightSource/hyperion/issues/1395
186
- LOGGER.info("Turning off Eiger dev/shm streaming")
187
- self.odin.fan.dev_shm_enable.set(0).wait()
188
- LOGGER.info("Eiger has successfully been stopped")
196
+ LOGGER.info("Already disarmed, doing nothing")
189
197
 
190
198
  def disable_roi_mode(self):
191
199
  return self.change_roi_mode(False)
@@ -194,13 +202,13 @@ class EigerDetector(Device, Stageable):
194
202
  return self.change_roi_mode(True)
195
203
 
196
204
  def change_roi_mode(self, enable: bool) -> StatusBase:
205
+ LOGGER.info(f"Changing ROI mode to {enable}")
197
206
  assert self.detector_params is not None
198
207
  detector_dimensions = (
199
208
  self.detector_params.detector_size_constants.roi_size_pixels
200
209
  if enable
201
210
  else self.detector_params.detector_size_constants.det_size_pixels
202
211
  )
203
-
204
212
  status = self.cam.roi_mode.set(
205
213
  1 if enable else 0, timeout=self.timeouts.general_status_timeout
206
214
  )
@@ -216,7 +224,6 @@ class EigerDetector(Device, Stageable):
216
224
  status &= self.odin.file_writer.num_col_chunks.set(
217
225
  detector_dimensions.width, timeout=self.timeouts.general_status_timeout
218
226
  )
219
-
220
227
  return status
221
228
 
222
229
  def set_cam_pvs(self) -> AndStatus:
@@ -296,6 +303,7 @@ class EigerDetector(Device, Stageable):
296
303
  self.detector_params.omega_increment,
297
304
  timeout=self.timeouts.general_status_timeout,
298
305
  )
306
+
299
307
  return status
300
308
 
301
309
  def set_detector_threshold(self, energy: float, tolerance: float = 0.1) -> Status:
@@ -1,5 +1,7 @@
1
1
  import asyncio
2
2
  from abc import ABC, abstractmethod
3
+ from functools import partial
4
+ from math import isclose
3
5
  from typing import Generic, TypeVar
4
6
 
5
7
  import numpy as np
@@ -307,13 +309,23 @@ class FastGridScanCommon(
307
309
  set_statuses = []
308
310
 
309
311
  LOGGER.info("Applying gridscan parameters...")
312
+
310
313
  # Create arguments for bps.mv
311
314
  for key, signal in self._movable_params.items():
312
315
  param_value = value.__dict__[key]
313
- set_statuses.append(await set_and_wait_for_value(signal, param_value)) # type: ignore
316
+
317
+ matcher = partial(isclose, param_value, abs_tol=0.001)
318
+
319
+ set_statuses.append(
320
+ set_and_wait_for_value(
321
+ signal, # type: ignore
322
+ param_value,
323
+ match_value=matcher,
324
+ )
325
+ )
314
326
 
315
327
  # Counter should always start at 0
316
- set_statuses.append(await set_and_wait_for_value(self.position_counter, 0))
328
+ set_statuses.append(set_and_wait_for_value(self.position_counter, 0))
317
329
 
318
330
  LOGGER.info("Gridscan parameters applied, waiting for sets to complete...")
319
331
 
@@ -0,0 +1,35 @@
1
+ from ophyd_async.core import Reference, derived_signal_r
2
+
3
+ from dodal.devices.aperturescatterguard import ApertureScatterguard
4
+ from dodal.devices.beamsize.beamsize import BeamsizeBase
5
+ from dodal.devices.i03.constants import BeamsizeConstants
6
+
7
+
8
+ class Beamsize(BeamsizeBase):
9
+ def __init__(self, aperture_scatterguard: ApertureScatterguard, name=""):
10
+ super().__init__(name=name)
11
+ self._aperture_scatterguard_ref = Reference(aperture_scatterguard)
12
+
13
+ with self.add_children_as_readables():
14
+ self.x_um = derived_signal_r(
15
+ self._get_beamsize_x,
16
+ aperture_radius=self._aperture_scatterguard_ref().radius,
17
+ derived_units="µm",
18
+ )
19
+ self.y_um = derived_signal_r(
20
+ self._get_beamsize_y,
21
+ aperture_radius=self._aperture_scatterguard_ref().radius,
22
+ derived_units="µm",
23
+ )
24
+
25
+ def _get_beamsize_x(
26
+ self,
27
+ aperture_radius: float,
28
+ ) -> float:
29
+ return min(aperture_radius, BeamsizeConstants.BEAM_WIDTH_UM)
30
+
31
+ def _get_beamsize_y(
32
+ self,
33
+ aperture_radius: float,
34
+ ) -> float:
35
+ return min(aperture_radius, BeamsizeConstants.BEAM_HEIGHT_UM)
@@ -0,0 +1,7 @@
1
+ from dataclasses import dataclass
2
+
3
+
4
+ @dataclass(frozen=True)
5
+ class BeamsizeConstants:
6
+ BEAM_WIDTH_UM = 80.0
7
+ BEAM_HEIGHT_UM = 20.0
@@ -5,7 +5,7 @@ from ophyd_async.core import AsyncStatus, Reference, StandardReadable
5
5
 
6
6
  from dodal.common.beamlines.beamline_parameters import get_beamline_parameters
7
7
  from dodal.devices.i03.dcm import DCM
8
- from dodal.devices.undulator import Undulator
8
+ from dodal.devices.undulator import UndulatorInKeV
9
9
  from dodal.log import LOGGER
10
10
 
11
11
  ENERGY_TIMEOUT_S: float = 30.0
@@ -31,7 +31,7 @@ class UndulatorDCM(StandardReadable, Movable[float]):
31
31
 
32
32
  def __init__(
33
33
  self,
34
- undulator: Undulator,
34
+ undulator: UndulatorInKeV,
35
35
  dcm: DCM,
36
36
  daq_configuration_path: str,
37
37
  name: str = "",
@@ -0,0 +1,45 @@
1
+ from ophyd_async.core import Reference, derived_signal_r
2
+
3
+ from dodal.devices.aperturescatterguard import ApertureScatterguard
4
+ from dodal.devices.beamsize.beamsize import BeamsizeBase
5
+ from dodal.devices.i04.transfocator import Transfocator
6
+
7
+
8
+ class Beamsize(BeamsizeBase):
9
+ def __init__(
10
+ self,
11
+ transfocator: Transfocator,
12
+ aperture_scatterguard: ApertureScatterguard,
13
+ name="",
14
+ ):
15
+ super().__init__(name=name)
16
+ self._transfocator_ref = Reference(transfocator)
17
+ self._aperture_scatterguard_ref = Reference(aperture_scatterguard)
18
+
19
+ with self.add_children_as_readables():
20
+ self.x_um = derived_signal_r(
21
+ self._get_beamsize_x,
22
+ transfocator_size_x=self._transfocator_ref().current_horizontal_size_rbv,
23
+ aperture_radius=self._aperture_scatterguard_ref().radius,
24
+ derived_units="µm",
25
+ )
26
+ self.y_um = derived_signal_r(
27
+ self._get_beamsize_y,
28
+ transfocator_size_y=self._transfocator_ref().current_vertical_size_rbv,
29
+ aperture_radius=self._aperture_scatterguard_ref().radius,
30
+ derived_units="µm",
31
+ )
32
+
33
+ def _get_beamsize_x(
34
+ self,
35
+ transfocator_size_x: float,
36
+ aperture_radius: float,
37
+ ) -> float:
38
+ return min(transfocator_size_x, aperture_radius)
39
+
40
+ def _get_beamsize_y(
41
+ self,
42
+ transfocator_size_y: float,
43
+ aperture_radius: float,
44
+ ) -> float:
45
+ return min(transfocator_size_y, aperture_radius)
@@ -0,0 +1,38 @@
1
+ import cv2
2
+ import numpy as np
3
+ from bluesky.protocols import Triggerable
4
+ from ophyd_async.core import AsyncStatus, StandardReadable, soft_signal_r_and_setter
5
+ from ophyd_async.epics.core import (
6
+ epics_signal_r,
7
+ )
8
+
9
+ # kernal size describes how many of the neigbouring pixels are used for the blur,
10
+ # higher kernal size means more of a blur effect
11
+ KERNAL_SIZE = (7, 7)
12
+
13
+
14
+ class MaxPixel(StandardReadable, Triggerable):
15
+ """Gets the max pixel (brightest pixel) from the image after some image processing."""
16
+
17
+ def __init__(self, prefix: str, name: str = "") -> None:
18
+ self.array_data = epics_signal_r(np.ndarray, f"pva://{prefix}PVA:ARRAY")
19
+ self.max_pixel_val, self._max_val_setter = soft_signal_r_and_setter(float)
20
+ super().__init__(name)
21
+
22
+ async def _convert_to_gray_and_blur(self):
23
+ """
24
+ Preprocess the image array data (convert to grayscale and apply a gaussian blur)
25
+ Image is converted to grayscale (using a weighted mean as green contributes more to brightness)
26
+ as we aren't interested in data relating to colour. A blur is then applied to mitigate
27
+ errors due to rogue hot pixels.
28
+ """
29
+ data = await self.array_data.get_value()
30
+ gray_arr = cv2.cvtColor(data, cv2.COLOR_BGR2GRAY)
31
+ return cv2.GaussianBlur(gray_arr, KERNAL_SIZE, 0)
32
+
33
+ @AsyncStatus.wrap
34
+ async def trigger(self):
35
+ arr = await self._convert_to_gray_and_blur()
36
+ max_val = float(np.max(arr)) # np.int64
37
+ assert isinstance(max_val, float)
38
+ self._max_val_setter(max_val)
@@ -1,5 +1,6 @@
1
1
  import json
2
2
  import pickle
3
+ import time
3
4
  from dataclasses import dataclass
4
5
  from enum import Enum
5
6
  from typing import TypedDict
@@ -21,6 +22,7 @@ from dodal.devices.oav.oav_calculations import (
21
22
  from dodal.log import LOGGER
22
23
 
23
24
  NO_MURKO_RESULT = (-1, -1)
25
+ RESULTS_COMPLETE_MESSAGE = "murko_results_complete"
24
26
 
25
27
 
26
28
  class MurkoMetadata(TypedDict):
@@ -71,7 +73,8 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
71
73
  solutions for y and z can be calculated using numpy's linear algebra library.
72
74
  """
73
75
 
74
- TIMEOUT_S = 2
76
+ GET_MESSAGE_TIMEOUT_S = 2
77
+ RESULTS_COMPLETE_TIMEOUT_S = 5
75
78
  PERCENTAGE_TO_USE = 25
76
79
  LEFTMOST_PIXEL_TO_USE = 10
77
80
  NUMBER_OF_WRONG_RESULTS_TO_LOG = 5
@@ -82,7 +85,6 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
82
85
  redis_password=RedisConstants.REDIS_PASSWORD,
83
86
  redis_db=RedisConstants.MURKO_REDIS_DB,
84
87
  name="",
85
- stop_angle=350,
86
88
  ):
87
89
  self.redis_client = StrictRedis(
88
90
  host=redis_host,
@@ -91,7 +93,6 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
91
93
  )
92
94
  self.pubsub = self.redis_client.pubsub()
93
95
  self.sample_id = soft_signal_rw(str) # Should get from redis
94
- self.stop_angle = stop_angle
95
96
 
96
97
  self._reset()
97
98
 
@@ -103,7 +104,7 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
103
104
  super().__init__(name=name)
104
105
 
105
106
  def _reset(self):
106
- self._last_omega = 0
107
+ self._last_omega = None
107
108
  self._results: list[MurkoResult] = []
108
109
 
109
110
  @AsyncStatus.wrap
@@ -120,14 +121,27 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
120
121
 
121
122
  @AsyncStatus.wrap
122
123
  async def trigger(self):
123
- # Wait for results
124
124
  sample_id = await self.sample_id.get_value()
125
- while self._last_omega < self.stop_angle:
125
+ t_last_result = time.time()
126
+ while True:
127
+ if time.time() - t_last_result > self.RESULTS_COMPLETE_TIMEOUT_S:
128
+ LOGGER.warning(
129
+ f"Time since last result > {self.RESULTS_COMPLETE_TIMEOUT_S}, expected to receive {RESULTS_COMPLETE_MESSAGE}"
130
+ )
131
+ break
126
132
  # waits here for next batch to be received
127
- message = await self.pubsub.get_message(timeout=self.TIMEOUT_S)
128
- if message is None:
129
- continue
130
- await self.process_batch(message, sample_id)
133
+ message = await self.pubsub.get_message(timeout=self.GET_MESSAGE_TIMEOUT_S)
134
+ if message and message["type"] == "message":
135
+ t_last_result = time.time()
136
+ data = pickle.loads(message["data"])
137
+
138
+ if data == RESULTS_COMPLETE_MESSAGE:
139
+ LOGGER.info(
140
+ f"Received results complete message: {RESULTS_COMPLETE_MESSAGE}"
141
+ )
142
+ break
143
+
144
+ await self.process_batch(data, sample_id)
131
145
 
132
146
  if not self._results:
133
147
  raise NoResultsFoundError("No results retrieved from Murko")
@@ -155,22 +169,18 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
155
169
  f"murko:{sample_id}:metadata", result.uuid, json.dumps(result.metadata)
156
170
  )
157
171
 
158
- async def process_batch(self, message: dict | None, sample_id: str):
159
- if message and message["type"] == "message":
160
- batch_results: list[dict] = pickle.loads(message["data"])
161
- for results in batch_results:
162
- for uuid, result in results.items():
163
- if metadata_str := await self.redis_client.hget( # type: ignore
164
- f"murko:{sample_id}:metadata", uuid
165
- ):
166
- LOGGER.info(
167
- f"Found metadata for uuid {uuid}, processing result"
168
- )
169
- self.process_result(
170
- result, MurkoMetadata(json.loads(metadata_str))
171
- )
172
- else:
173
- LOGGER.info(f"Found no metadata for uuid {uuid}")
172
+ async def process_batch(
173
+ self, batch_results: list[tuple[str, dict]], sample_id: str
174
+ ):
175
+ for result_with_uuid in batch_results:
176
+ uuid, result = result_with_uuid
177
+ if metadata_str := await self.redis_client.hget( # type: ignore
178
+ f"murko:{sample_id}:metadata", uuid
179
+ ):
180
+ LOGGER.info(f"Found metadata for uuid {uuid}, processing result")
181
+ self.process_result(result, MurkoMetadata(json.loads(metadata_str)))
182
+ else:
183
+ LOGGER.info(f"Found no metadata for uuid {uuid}")
174
184
 
175
185
  def process_result(self, result: dict, metadata: MurkoMetadata):
176
186
  """Uses the 'most_likely_click' coordinates from Murko to calculate the
@@ -1,5 +1,4 @@
1
1
  import asyncio
2
- import math
3
2
 
4
3
  from ophyd_async.core import (
5
4
  AsyncStatus,
@@ -9,6 +8,7 @@ from ophyd_async.core import (
9
8
  )
10
9
  from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
11
10
 
11
+ from dodal.common.device_utils import periodic_reminder
12
12
  from dodal.log import LOGGER
13
13
 
14
14
 
@@ -23,15 +23,15 @@ class Transfocator(StandardReadable):
23
23
  """
24
24
 
25
25
  def __init__(self, prefix: str, name: str = ""):
26
+ self._vert_size_calc_sp = epics_signal_rw(float, prefix + "VERT_REQ")
27
+ self._num_lenses_calc_rbv = epics_signal_r(float, prefix + "LENS_PRED")
28
+ self.start = epics_signal_rw(int, prefix + "START.PROC")
29
+ self.start_rbv = epics_signal_r(int, prefix + "START_RBV")
30
+
26
31
  with self.add_children_as_readables():
27
- self.beamsize_set_microns = epics_signal_rw(float, prefix + "VERT_REQ")
28
- self.predicted_vertical_num_lenses = epics_signal_rw(
29
- float, prefix + "LENS_PRED"
30
- )
31
32
  self.number_filters_sp = epics_signal_rw(int, prefix + "NUM_FILTERS")
32
- self.start = epics_signal_rw(int, prefix + "START.PROC")
33
- self.start_rbv = epics_signal_r(int, prefix + "START_RBV")
34
- self.vertical_lens_rbv = epics_signal_r(float, prefix + "VER")
33
+ self.current_horizontal_size_rbv = epics_signal_r(float, prefix + "HOR")
34
+ self.current_vertical_size_rbv = epics_signal_r(float, prefix + "VER")
35
35
 
36
36
  self.TIMEOUT = 120
37
37
 
@@ -41,14 +41,10 @@ class Transfocator(StandardReadable):
41
41
  # We can only put an integer number of lenses in the beam but the
42
42
  # calculation in the IOC returns the theoretical float number of lenses
43
43
  value = round(value)
44
- LOGGER.info(f"Transfocator setting {value} filters")
45
44
  await self.number_filters_sp.set(value)
46
45
  await self.start.set(1)
47
- LOGGER.info("Waiting for start_rbv to change to 1")
48
46
  await wait_for_value(self.start_rbv, 1, self.TIMEOUT)
49
- LOGGER.info("Waiting for start_rbv to change to 0")
50
47
  await wait_for_value(self.start_rbv, 0, self.TIMEOUT)
51
- self.latest_pred_vertical_num_lenses = value
52
48
 
53
49
  @AsyncStatus.wrap
54
50
  async def set(self, value: float):
@@ -59,29 +55,27 @@ class Transfocator(StandardReadable):
59
55
  4. Start the device moving
60
56
  5. Wait for the start_rbv goes high and low again
61
57
  """
62
- self.latest_pred_vertical_num_lenses = (
63
- await self.predicted_vertical_num_lenses.get_value()
64
- )
65
-
66
58
  LOGGER.info(f"Transfocator setting {value} beamsize")
67
59
 
68
- if await self.beamsize_set_microns.get_value() != value:
69
- # Logic in the IOC calculates predicted_vertical_num_lenses when beam_set_microns changes
60
+ # Logic in the IOC calculates _num_lenses_calc_rbv when _vert_size_calc_sp changes
61
+
62
+ # Register an observer before setting _vert_size_calc_sp to ensure we don't miss changes
63
+ num_lenses_calc_iterator = observe_value(
64
+ self._num_lenses_calc_rbv, timeout=self.TIMEOUT
65
+ )
66
+
67
+ await anext(num_lenses_calc_iterator)
68
+ await self._vert_size_calc_sp.set(value)
69
+ calc_lenses = await anext(num_lenses_calc_iterator)
70
70
 
71
- # Register an observer before setting beamsize_set_microns to ensure we don't miss changes
72
- predicted_vertical_num_lenses_iterator = observe_value(
73
- self.predicted_vertical_num_lenses, timeout=self.TIMEOUT
74
- )
75
- # Keep initial prediction before setting to later compare with change after setting
76
- current_prediction = await anext(predicted_vertical_num_lenses_iterator)
77
- await self.beamsize_set_microns.set(value)
78
- accepted_prediction = await anext(predicted_vertical_num_lenses_iterator)
79
- if not math.isclose(current_prediction, accepted_prediction, abs_tol=1e-8):
80
- await self.set_based_on_prediction(accepted_prediction)
71
+ async with periodic_reminder(
72
+ f"Waiting for transfocator to insert {calc_lenses} into beam"
73
+ ):
74
+ await self.set_based_on_prediction(calc_lenses)
81
75
 
82
76
  number_filters_rbv, vertical_lens_size_rbv = await asyncio.gather(
83
77
  self.number_filters_sp.get_value(),
84
- self.vertical_lens_rbv.get_value(),
78
+ self.current_vertical_size_rbv.get_value(),
85
79
  )
86
80
 
87
81
  LOGGER.info(