dls-dodal 1.68.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 (57) hide show
  1. {dls_dodal-1.68.0.dist-info → dls_dodal-1.69.0.dist-info}/METADATA +1 -31
  2. {dls_dodal-1.68.0.dist-info → dls_dodal-1.69.0.dist-info}/RECORD +57 -49
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/adsim.py +30 -23
  5. dodal/beamlines/i02_1.py +14 -42
  6. dodal/beamlines/i02_2.py +5 -11
  7. dodal/beamlines/i03.py +4 -1
  8. dodal/beamlines/i03_supervisor.py +19 -0
  9. dodal/beamlines/i04.py +74 -179
  10. dodal/beamlines/i05.py +8 -0
  11. dodal/beamlines/i06_1.py +24 -0
  12. dodal/beamlines/i09.py +53 -9
  13. dodal/beamlines/i09_1.py +8 -0
  14. dodal/beamlines/i09_2.py +4 -4
  15. dodal/beamlines/i16.py +11 -0
  16. dodal/beamlines/i20_1.py +14 -0
  17. dodal/beamlines/i21.py +12 -4
  18. dodal/beamlines/i23.py +19 -25
  19. dodal/beamlines/i24.py +55 -105
  20. dodal/beamlines/p60.py +11 -1
  21. dodal/common/__init__.py +2 -1
  22. dodal/common/maths.py +80 -0
  23. dodal/devices/eiger.py +29 -14
  24. dodal/devices/electron_analyser/base/__init__.py +3 -3
  25. dodal/devices/electron_analyser/base/base_controller.py +19 -8
  26. dodal/devices/electron_analyser/base/base_enums.py +0 -5
  27. dodal/devices/electron_analyser/base/base_region.py +2 -1
  28. dodal/devices/electron_analyser/base/energy_sources.py +27 -26
  29. dodal/devices/electron_analyser/specs/specs_detector.py +7 -6
  30. dodal/devices/electron_analyser/vgscienta/vgscienta_detector.py +7 -6
  31. dodal/devices/fast_shutter.py +108 -25
  32. dodal/devices/i04/beam_centre.py +84 -0
  33. dodal/devices/i04/max_pixel.py +4 -17
  34. dodal/devices/i04/murko_results.py +18 -3
  35. dodal/devices/i10/i10_apple2.py +6 -6
  36. dodal/devices/i17/i17_apple2.py +6 -6
  37. dodal/devices/i24/commissioning_jungfrau.py +9 -10
  38. dodal/devices/insertion_device/__init__.py +12 -8
  39. dodal/devices/insertion_device/apple2_controller.py +380 -0
  40. dodal/devices/insertion_device/apple2_undulator.py +152 -531
  41. dodal/devices/insertion_device/energy.py +88 -0
  42. dodal/devices/insertion_device/energy_motor_lookup.py +1 -1
  43. dodal/devices/insertion_device/lookup_table_models.py +2 -2
  44. dodal/devices/insertion_device/polarisation.py +36 -0
  45. dodal/devices/oav/oav_detector.py +66 -1
  46. dodal/devices/oav/utils.py +17 -0
  47. dodal/devices/robot.py +35 -18
  48. dodal/devices/selectable_source.py +38 -0
  49. dodal/devices/zebra/zebra.py +15 -0
  50. dodal/devices/zebra/zebra_constants_mapping.py +1 -0
  51. dodal/plans/configure_arm_trigger_and_disarm_detector.py +0 -1
  52. dodal/testing/__init__.py +0 -0
  53. {dls_dodal-1.68.0.dist-info → dls_dodal-1.69.0.dist-info}/WHEEL +0 -0
  54. {dls_dodal-1.68.0.dist-info → dls_dodal-1.69.0.dist-info}/entry_points.txt +0 -0
  55. {dls_dodal-1.68.0.dist-info → dls_dodal-1.69.0.dist-info}/licenses/LICENSE +0 -0
  56. {dls_dodal-1.68.0.dist-info → dls_dodal-1.69.0.dist-info}/top_level.txt +0 -0
  57. /dodal/devices/insertion_device/{id_enum.py → enum.py} +0 -0
@@ -12,10 +12,9 @@ from dodal.devices.electron_analyser.base.base_region import (
12
12
  GenericRegion,
13
13
  TAbstractBaseRegion,
14
14
  )
15
- from dodal.devices.electron_analyser.base.energy_sources import (
16
- AbstractEnergySource,
17
- DualEnergySource,
18
- )
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
19
18
 
20
19
 
21
20
  class ElectronAnalyserController(
@@ -32,7 +31,9 @@ class ElectronAnalyserController(
32
31
  self,
33
32
  driver: TAbstractAnalyserDriverIO,
34
33
  energy_source: AbstractEnergySource,
35
- deadtime: float,
34
+ shutter: FastShutter | None = None,
35
+ source_selector: SourceSelector | None = None,
36
+ deadtime: float = 0,
36
37
  image_mode: ADImageMode = ADImageMode.SINGLE,
37
38
  ):
38
39
  """
@@ -45,13 +46,19 @@ class ElectronAnalyserController(
45
46
  image_mode: The image mode to configure the driver with before measuring.
46
47
  """
47
48
  self.energy_source = energy_source
49
+ self.shutter = shutter
50
+ self.source_selector = source_selector
48
51
  super().__init__(driver, deadtime, image_mode)
49
52
 
50
- async def setup_with_region(self, region: TAbstractBaseRegion):
53
+ async def setup_with_region(self, region: TAbstractBaseRegion) -> None:
51
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)
52
61
 
53
- if isinstance(self.energy_source, DualEnergySource):
54
- self.energy_source.selected_source.set(region.excitation_energy_source)
55
62
  excitation_energy = await self.energy_source.energy.get_value()
56
63
  epics_region = region.prepare_for_epics(excitation_energy)
57
64
  await self.driver.set(epics_region)
@@ -62,6 +69,10 @@ class ElectronAnalyserController(
62
69
  # axis calculation.
63
70
  excitation_energy = await self.energy_source.energy.get_value()
64
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
+
65
76
  await super().prepare(trigger_info)
66
77
 
67
78
 
@@ -4,8 +4,3 @@ from ophyd_async.core import StrictEnum
4
4
  class EnergyMode(StrictEnum):
5
5
  KINETIC = "Kinetic"
6
6
  BINDING = "Binding"
7
-
8
-
9
- class SelectedSource(StrictEnum):
10
- SOURCE1 = "source1"
11
- SOURCE2 = "source2"
@@ -6,11 +6,12 @@ from typing import Generic, Self, TypeAlias, TypeVar
6
6
  from ophyd_async.core import StrictEnum, SupersetEnum
7
7
  from pydantic import BaseModel, Field, model_validator
8
8
 
9
- from dodal.devices.electron_analyser.base.base_enums import EnergyMode, SelectedSource
9
+ from dodal.devices.electron_analyser.base.base_enums import EnergyMode
10
10
  from dodal.devices.electron_analyser.base.base_util import (
11
11
  to_binding_energy,
12
12
  to_kinetic_energy,
13
13
  )
14
+ from dodal.devices.selectable_source import SelectedSource
14
15
 
15
16
  AnyAcqMode: TypeAlias = StrictEnum
16
17
  AnyLensMode: TypeAlias = SupersetEnum | StrictEnum
@@ -3,14 +3,14 @@ from abc import abstractmethod
3
3
  from ophyd_async.core import (
4
4
  Reference,
5
5
  SignalR,
6
+ SignalRW,
6
7
  StandardReadable,
7
8
  StandardReadableFormat,
8
9
  derived_signal_r,
9
10
  soft_signal_r_and_setter,
10
- soft_signal_rw,
11
11
  )
12
12
 
13
- from dodal.devices.electron_analyser.base.base_enums import SelectedSource
13
+ from dodal.devices.selectable_source import SelectedSource, get_obj_from_selected_source
14
14
 
15
15
 
16
16
  class AbstractEnergySource(StandardReadable):
@@ -51,51 +51,52 @@ class EnergySource(AbstractEnergySource):
51
51
  return self._source_ref()
52
52
 
53
53
 
54
+ def get_float_from_selected_source(
55
+ selected: SelectedSource, s1: float, s2: float
56
+ ) -> float:
57
+ """Wrapper function to provide type hints for derived signal."""
58
+ return get_obj_from_selected_source(selected, s1, s2)
59
+
60
+
54
61
  class DualEnergySource(AbstractEnergySource):
55
62
  """
56
63
  Holds two EnergySource devices and provides a signal to read energy depending on
57
- which source is selected. This is controlled by a selected_source signal which can
58
- switch source using SelectedSource enum. Both sources energy is recorded in the
59
- read, the energy signal is used as a helper signal to know which source is being
60
- used.
64
+ which source is selected. The energy is the one that corrosponds to the
65
+ selected_source signal. For example, selected_source is source1 if selected_source
66
+ is at SelectedSource.SOURCE1 and vise versa for source2 and SelectedSource.SOURCE2.
61
67
  """
62
68
 
63
69
  def __init__(
64
- self, source1: SignalR[float], source2: SignalR[float], name: str = ""
70
+ self,
71
+ source1: SignalR[float],
72
+ source2: SignalR[float],
73
+ selected_source: SignalRW[SelectedSource],
74
+ name: str = "",
65
75
  ):
66
76
  """
67
77
  Args:
68
- source1: Default energy signal to select.
69
- source2: Secondary energy signal to select.
70
- name: name of this device.
78
+ source1: Energy source that corrosponds to SelectedSource.SOURCE1.
79
+ source2: Energy source that corrosponds to SelectedSource.SOURCE2.
80
+ selected_source: Signal that decides the active energy source.
81
+ name: Name of this device.
71
82
  """
72
83
 
84
+ self.selected_source_ref = Reference(selected_source)
73
85
  with self.add_children_as_readables():
74
- self.selected_source = soft_signal_rw(
75
- SelectedSource, initial_value=SelectedSource.SOURCE1
76
- )
77
86
  self.source1 = EnergySource(source1)
78
87
  self.source2 = EnergySource(source2)
79
88
 
80
89
  self._selected_energy = derived_signal_r(
81
- self._get_excitation_energy,
90
+ get_float_from_selected_source,
82
91
  "eV",
83
- selected_source=self.selected_source,
84
- source1=self.source1.energy,
85
- source2=self.source2.energy,
92
+ selected=self.selected_source_ref(),
93
+ s1=self.source1.energy,
94
+ s2=self.source2.energy,
86
95
  )
96
+ self.add_readables([selected_source])
87
97
 
88
98
  super().__init__(name)
89
99
 
90
- def _get_excitation_energy(
91
- self, selected_source: SelectedSource, source1: float, source2: float
92
- ) -> float:
93
- match selected_source:
94
- case SelectedSource.SOURCE1:
95
- return source1
96
- case SelectedSource.SOURCE2:
97
- return source2
98
-
99
100
  @property
100
101
  def energy(self) -> SignalR[float]:
101
102
  return self._selected_energy
@@ -5,15 +5,14 @@ from dodal.devices.electron_analyser.base.base_controller import (
5
5
  )
6
6
  from dodal.devices.electron_analyser.base.base_detector import ElectronAnalyserDetector
7
7
  from dodal.devices.electron_analyser.base.base_region import TLensMode, TPsuMode
8
- from dodal.devices.electron_analyser.base.energy_sources import (
9
- DualEnergySource,
10
- EnergySource,
11
- )
8
+ from dodal.devices.electron_analyser.base.energy_sources import AbstractEnergySource
12
9
  from dodal.devices.electron_analyser.specs.specs_driver_io import SpecsAnalyserDriverIO
13
10
  from dodal.devices.electron_analyser.specs.specs_region import (
14
11
  SpecsRegion,
15
12
  SpecsSequence,
16
13
  )
14
+ from dodal.devices.fast_shutter import FastShutter
15
+ from dodal.devices.selectable_source import SourceSelector
17
16
 
18
17
 
19
18
  class SpecsDetector(
@@ -29,7 +28,9 @@ class SpecsDetector(
29
28
  prefix: str,
30
29
  lens_mode_type: type[TLensMode],
31
30
  psu_mode_type: type[TPsuMode],
32
- energy_source: DualEnergySource | EnergySource,
31
+ energy_source: AbstractEnergySource,
32
+ shutter: FastShutter | None = None,
33
+ source_selector: SourceSelector | None = None,
33
34
  name: str = "",
34
35
  ):
35
36
  # Save to class so takes part with connect()
@@ -39,7 +40,7 @@ class SpecsDetector(
39
40
 
40
41
  controller = ElectronAnalyserController[
41
42
  SpecsAnalyserDriverIO[TLensMode, TPsuMode], SpecsRegion[TLensMode, TPsuMode]
42
- ](self.driver, energy_source, 0)
43
+ ](self.driver, energy_source, shutter, source_selector)
43
44
 
44
45
  sequence_class = SpecsSequence[lens_mode_type, psu_mode_type]
45
46
 
@@ -5,10 +5,7 @@ from dodal.devices.electron_analyser.base.base_controller import (
5
5
  )
6
6
  from dodal.devices.electron_analyser.base.base_detector import ElectronAnalyserDetector
7
7
  from dodal.devices.electron_analyser.base.base_region import TLensMode, TPsuMode
8
- from dodal.devices.electron_analyser.base.energy_sources import (
9
- DualEnergySource,
10
- EnergySource,
11
- )
8
+ from dodal.devices.electron_analyser.base.energy_sources import AbstractEnergySource
12
9
  from dodal.devices.electron_analyser.vgscienta.vgscienta_driver_io import (
13
10
  VGScientaAnalyserDriverIO,
14
11
  )
@@ -17,6 +14,8 @@ from dodal.devices.electron_analyser.vgscienta.vgscienta_region import (
17
14
  VGScientaRegion,
18
15
  VGScientaSequence,
19
16
  )
17
+ from dodal.devices.fast_shutter import FastShutter
18
+ from dodal.devices.selectable_source import SourceSelector
20
19
 
21
20
 
22
21
  class VGScientaDetector(
@@ -33,7 +32,9 @@ class VGScientaDetector(
33
32
  lens_mode_type: type[TLensMode],
34
33
  psu_mode_type: type[TPsuMode],
35
34
  pass_energy_type: type[TPassEnergyEnum],
36
- energy_source: DualEnergySource | EnergySource,
35
+ energy_source: AbstractEnergySource,
36
+ shutter: FastShutter | None = None,
37
+ source_selector: SourceSelector | None = None,
37
38
  name: str = "",
38
39
  ):
39
40
  # Save to class so takes part with connect()
@@ -44,7 +45,7 @@ class VGScientaDetector(
44
45
  controller = ElectronAnalyserController[
45
46
  VGScientaAnalyserDriverIO[TLensMode, TPsuMode, TPassEnergyEnum],
46
47
  VGScientaRegion[TLensMode, TPassEnergyEnum],
47
- ](self.driver, energy_source, 0)
48
+ ](self.driver, energy_source, shutter, source_selector)
48
49
 
49
50
  sequence_class = VGScientaSequence[
50
51
  lens_mode_type, psu_mode_type, pass_energy_type
@@ -1,19 +1,26 @@
1
- from typing import TypeVar
1
+ from typing import Generic, Protocol, TypeVar
2
2
 
3
3
  from bluesky.protocols import Movable
4
4
  from ophyd_async.core import (
5
5
  AsyncStatus,
6
6
  EnumTypes,
7
+ Reference,
8
+ SignalRW,
7
9
  StandardReadable,
10
+ StandardReadableFormat,
11
+ derived_signal_rw,
12
+ soft_signal_r_and_setter,
8
13
  )
9
14
  from ophyd_async.epics.core import epics_signal_rw
10
15
 
11
- StrictEnumT = TypeVar("StrictEnumT", bound=EnumTypes)
16
+ from dodal.devices.selectable_source import SelectedSource, get_obj_from_selected_source
12
17
 
18
+ EnumTypesT = TypeVar("EnumTypesT", bound=EnumTypes)
13
19
 
14
- class GenericFastShutter(StandardReadable, Movable[StrictEnumT]):
20
+
21
+ class FastShutter(Movable[EnumTypesT], Protocol, Generic[EnumTypesT]):
15
22
  """
16
- Basic enum device specialised for a fast shutter with configured open_state and
23
+ Enum device specialised for a fast shutter with configured open_state and
17
24
  close_state so it is generic enough to be used with any device or plan without
18
25
  knowing the specific enum to use.
19
26
 
@@ -25,11 +32,28 @@ class GenericFastShutter(StandardReadable, Movable[StrictEnumT]):
25
32
  run_engine(bps.mv(shutter, shutter.close_state))
26
33
  """
27
34
 
35
+ open_state: EnumTypesT
36
+ close_state: EnumTypesT
37
+ shutter_state: SignalRW[EnumTypesT]
38
+
39
+ @AsyncStatus.wrap
40
+ async def set(self, state: EnumTypesT):
41
+ await self.shutter_state.set(state)
42
+
43
+
44
+ class GenericFastShutter(
45
+ StandardReadable, FastShutter[EnumTypesT], Generic[EnumTypesT]
46
+ ):
47
+ """
48
+ Implementation of fast shutter that connects to an epics pv. This pv is an enum that
49
+ controls the open and close state of the shutter.
50
+ """
51
+
28
52
  def __init__(
29
53
  self,
30
54
  pv: str,
31
- open_state: StrictEnumT,
32
- close_state: StrictEnumT,
55
+ open_state: EnumTypesT,
56
+ close_state: EnumTypesT,
33
57
  name: str = "",
34
58
  ):
35
59
  """
@@ -41,29 +65,88 @@ class GenericFastShutter(StandardReadable, Movable[StrictEnumT]):
41
65
  self.open_state = open_state
42
66
  self.close_state = close_state
43
67
  with self.add_children_as_readables():
44
- self.state = epics_signal_rw(type(self.open_state), pv)
68
+ self.shutter_state = epics_signal_rw(type(self.open_state), pv)
45
69
  super().__init__(name)
46
70
 
47
- @AsyncStatus.wrap
48
- async def set(self, value: StrictEnumT) -> None:
49
- await self.state.set(value)
50
71
 
51
- async def is_open(self) -> bool:
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:
72
+ class DualFastShutter(StandardReadable, FastShutter[EnumTypesT], Generic[EnumTypesT]):
73
+ """
74
+ A fast shutter device that handles the positions of two other fast shutters. The
75
+ "active" shutter is the one that corrosponds to the selected_shutter signal. For
76
+ example, active shutter is shutter1 if selected_source is at SelectedSource.SOURCE1
77
+ and vise versa for shutter2 and SelectedSource.SOURCE2. Whenever a move is done on
78
+ this device, the inactive shutter is always set to the close_state.
79
+ """
55
80
 
56
- from bluesky import plan_stubs as bps
57
- is_open = yield from bps.rd(shutter.state) == shutter.open_state
81
+ def __init__(
82
+ self,
83
+ shutter1: GenericFastShutter[EnumTypesT],
84
+ shutter2: GenericFastShutter[EnumTypesT],
85
+ selected_source: SignalRW[SelectedSource],
86
+ name: str = "",
87
+ ):
58
88
  """
59
- return await self.state.get_value() == self.open_state
60
-
61
- async def is_closed(self) -> bool:
89
+ Arguments:
90
+ shutter1: Active shutter that corrosponds to SelectedSource.SOURCE1.
91
+ shutter2: Active shutter that corrosponds to SelectedSource.SOURCE2.
92
+ selected_source: Signal that decides the active shutter.
93
+ name: Name of this device.
62
94
  """
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:
95
+ self._validate_shutter_states(shutter1.open_state, shutter2.open_state)
96
+ self._validate_shutter_states(shutter1.close_state, shutter2.close_state)
97
+ self.open_state = shutter1.open_state
98
+ self.close_state = shutter1.close_state
65
99
 
66
- from bluesky import plan_stubs as bps
67
- is_closed = yield from bps.rd(shutter.state) == shutter.close_state
68
- """
69
- return await self.state.get_value() == self.close_state
100
+ self._shutter1_ref = Reference(shutter1)
101
+ self._shutter2_ref = Reference(shutter2)
102
+ self._selected_shutter_ref = Reference(selected_source)
103
+
104
+ with self.add_children_as_readables():
105
+ self.shutter_state = derived_signal_rw(
106
+ self._read_shutter_state,
107
+ self._set_shutter_state,
108
+ selected_shutter=selected_source,
109
+ shutter1=shutter1.shutter_state,
110
+ shutter2=shutter2.shutter_state,
111
+ )
112
+
113
+ with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
114
+ self.shutter1_device_name, _ = soft_signal_r_and_setter(
115
+ str, initial_value=shutter1.name
116
+ )
117
+ self.shutter2_device_name, _ = soft_signal_r_and_setter(
118
+ str, initial_value=shutter2.name
119
+ )
120
+
121
+ self.add_readables([shutter1, shutter2, selected_source])
122
+
123
+ super().__init__(name)
124
+
125
+ def _validate_shutter_states(self, state1: EnumTypesT, state2: EnumTypesT) -> None:
126
+ if state1 is not state2:
127
+ raise ValueError(
128
+ f"{state1} is not same value as {state2}. They must be the same to be compatible."
129
+ )
130
+
131
+ def _read_shutter_state(
132
+ self,
133
+ selected_shutter: SelectedSource,
134
+ shutter1: EnumTypesT,
135
+ shutter2: EnumTypesT,
136
+ ) -> EnumTypesT:
137
+ return get_obj_from_selected_source(selected_shutter, shutter1, shutter2)
138
+
139
+ async def _set_shutter_state(self, value: EnumTypesT):
140
+ selected_shutter = await self._selected_shutter_ref().get_value()
141
+ active_shutter = get_obj_from_selected_source(
142
+ selected_shutter,
143
+ self._shutter1_ref(),
144
+ self._shutter2_ref(),
145
+ )
146
+ inactive_shutter = get_obj_from_selected_source(
147
+ selected_shutter,
148
+ self._shutter2_ref(),
149
+ self._shutter1_ref(),
150
+ )
151
+ await inactive_shutter.set(inactive_shutter.close_state)
152
+ await active_shutter.set(value)
@@ -0,0 +1,84 @@
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
+ from dodal.devices.oav.utils import convert_to_gray_and_blur
10
+ from dodal.log import LOGGER
11
+
12
+ # Constant was chosen from trial and error with test images
13
+ ADDITIONAL_BINARY_THRESH = 20
14
+
15
+
16
+ def convert_image_to_binary(image: np.ndarray):
17
+ """
18
+ Creates a binary image from OAV image array data.
19
+
20
+ Pixels of the input image are converted to one of two values (a high and a low value).
21
+ Otsu's method is used for automatic thresholding.
22
+ See https://docs.opencv.org/4.x/d7/d4d/tutorial_py_thresholding.html.
23
+ The threshold is increased by ADDITIONAL_BINARY_THRESH in order to get more of
24
+ the centre of the beam.
25
+ """
26
+ max_pixel_value = 255
27
+
28
+ blurred_image = convert_to_gray_and_blur(image)
29
+
30
+ threshold_value, _ = cv2.threshold(
31
+ blurred_image, 0, max_pixel_value, cv2.THRESH_BINARY + cv2.THRESH_OTSU
32
+ )
33
+
34
+ # Adjusting because the inner beam is less noisy compared to the outer
35
+ threshold_value += ADDITIONAL_BINARY_THRESH
36
+
37
+ _, thresholded_image = cv2.threshold(
38
+ blurred_image, threshold_value, max_pixel_value, cv2.THRESH_BINARY
39
+ )
40
+
41
+ LOGGER.info(f"Image binarised with threshold of {threshold_value}")
42
+ return thresholded_image
43
+
44
+
45
+ class CentreEllipseMethod(StandardReadable, Triggerable):
46
+ """
47
+ Upon triggering, fits an ellipse to a binary image from the area detector defined by
48
+ the prefix.
49
+
50
+ This is used, in conjunction with a scintillator, to determine the centre of the beam
51
+ on the image.
52
+ """
53
+
54
+ def __init__(self, prefix: str, name: str = ""):
55
+ self.oav_array_signal = epics_signal_r(np.ndarray, f"pva://{prefix}PVA:ARRAY")
56
+
57
+ self.center_x_val, self._center_x_val_setter = soft_signal_r_and_setter(float)
58
+ self.center_y_val, self._center_y_val_setter = soft_signal_r_and_setter(float)
59
+ super().__init__(name)
60
+
61
+ def _fit_ellipse(self, binary_img: cv2.typing.MatLike) -> cv2.typing.RotatedRect:
62
+ contours, _ = cv2.findContours(
63
+ binary_img, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_NONE
64
+ )
65
+ if not contours:
66
+ raise ValueError("No contours found in image.")
67
+
68
+ largest_contour = max(contours, key=cv2.contourArea)
69
+ if len(largest_contour) < 5:
70
+ raise ValueError(
71
+ f"Not enough points to fit an ellipse. Found {largest_contour} points and need at least 5."
72
+ )
73
+
74
+ return cv2.fitEllipse(largest_contour)
75
+
76
+ @AsyncStatus.wrap
77
+ async def trigger(self):
78
+ array_data = await self.oav_array_signal.get_value()
79
+ binary = convert_image_to_binary(array_data)
80
+ ellipse_fit = self._fit_ellipse(binary)
81
+ centre_x = ellipse_fit[0][0]
82
+ centre_y = ellipse_fit[0][1]
83
+ self._center_x_val_setter(centre_x)
84
+ self._center_y_val_setter(centre_y)
@@ -1,4 +1,3 @@
1
- import cv2
2
1
  import numpy as np
3
2
  from bluesky.protocols import Triggerable
4
3
  from ophyd_async.core import AsyncStatus, StandardReadable, soft_signal_r_and_setter
@@ -6,9 +5,7 @@ from ophyd_async.epics.core import (
6
5
  epics_signal_r,
7
6
  )
8
7
 
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)
8
+ from dodal.devices.oav.utils import convert_to_gray_and_blur
12
9
 
13
10
 
14
11
  class MaxPixel(StandardReadable, Triggerable):
@@ -19,20 +16,10 @@ class MaxPixel(StandardReadable, Triggerable):
19
16
  self.max_pixel_val, self._max_val_setter = soft_signal_r_and_setter(float)
20
17
  super().__init__(name)
21
18
 
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
19
  @AsyncStatus.wrap
34
20
  async def trigger(self):
35
- arr = await self._convert_to_gray_and_blur()
36
- max_val = float(np.max(arr)) # np.int64
21
+ img_data = await self.array_data.get_value()
22
+ arr = convert_to_gray_and_blur(img_data)
23
+ max_val = float(np.max(arr))
37
24
  assert isinstance(max_val, float)
38
25
  self._max_val_setter(max_val)
@@ -13,7 +13,7 @@ from ophyd_async.core import (
13
13
  soft_signal_r_and_setter,
14
14
  soft_signal_rw,
15
15
  )
16
- from redis.asyncio import StrictRedis
16
+ from redis.asyncio import ConnectionError, StrictRedis
17
17
 
18
18
  from dodal.devices.i04.constants import RedisConstants
19
19
  from dodal.devices.oav.oav_calculations import (
@@ -103,13 +103,25 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
103
103
  self.z_mm, self._z_mm_setter = soft_signal_r_and_setter(float)
104
104
  super().__init__(name=name)
105
105
 
106
+ async def _check_redis_connection(self):
107
+ try:
108
+ await self.redis_client.ping() # type: ignore
109
+ return True
110
+ except ConnectionError:
111
+ LOGGER.warning(
112
+ f"Failed to connect to redis: {self.redis_client}. Murko results device will not trigger"
113
+ )
114
+ return False
115
+
106
116
  def _reset(self):
107
117
  self._last_omega = None
108
118
  self._results: list[MurkoResult] = []
109
119
 
110
120
  @AsyncStatus.wrap
111
121
  async def stage(self):
112
- await self.pubsub.subscribe("murko-results")
122
+ self.redis_connected = await self._check_redis_connection()
123
+ if self.redis_connected:
124
+ await self.pubsub.subscribe("murko-results")
113
125
  self._x_mm_setter(0)
114
126
  self._y_mm_setter(0)
115
127
  self._z_mm_setter(0)
@@ -117,10 +129,13 @@ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
117
129
  @AsyncStatus.wrap
118
130
  async def unstage(self):
119
131
  self._reset()
120
- await self.pubsub.unsubscribe()
132
+ if self.redis_connected:
133
+ await self.pubsub.unsubscribe()
121
134
 
122
135
  @AsyncStatus.wrap
123
136
  async def trigger(self):
137
+ if not self.redis_connected:
138
+ return
124
139
  sample_id = await self.sample_id.get_value()
125
140
  t_last_result = time.time()
126
141
  while True:
@@ -22,7 +22,7 @@ from dodal.devices.insertion_device import (
22
22
  UndulatorPhaseAxes,
23
23
  )
24
24
  from dodal.devices.insertion_device.energy_motor_lookup import EnergyMotorLookup
25
- from dodal.devices.insertion_device.id_enum import Pol
25
+ from dodal.devices.insertion_device.enum import Pol
26
26
 
27
27
  ROW_PHASE_MOTOR_TOLERANCE = 0.004
28
28
  MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
@@ -141,12 +141,12 @@ class I10Apple2Controller(Apple2Controller[I10Apple2]):
141
141
  def _get_apple2_value(self, gap: float, phase: float, pol: Pol) -> Apple2Val:
142
142
  phase3 = phase * (-1 if pol == Pol.LA else 1)
143
143
  return Apple2Val(
144
- gap=f"{gap:.6f}",
144
+ gap=gap,
145
145
  phase=Apple2PhasesVal(
146
- top_outer=f"{phase:.6f}",
147
- top_inner="0.0",
148
- btm_inner=f"{phase3:.6f}",
149
- btm_outer="0.0",
146
+ top_outer=phase,
147
+ top_inner=0.0,
148
+ btm_inner=phase3,
149
+ btm_outer=0.0,
150
150
  ),
151
151
  )
152
152
 
@@ -1,4 +1,4 @@
1
- from dodal.devices.insertion_device.apple2_undulator import (
1
+ from dodal.devices.insertion_device import (
2
2
  Apple2,
3
3
  Apple2Controller,
4
4
  Apple2PhasesVal,
@@ -56,11 +56,11 @@ class I17Apple2Controller(Apple2Controller[Apple2[UndulatorPhaseAxes]]):
56
56
 
57
57
  def _get_apple2_value(self, gap: float, phase: float, pol: Pol) -> Apple2Val:
58
58
  return Apple2Val(
59
- gap=f"{gap:.6f}",
59
+ gap=gap,
60
60
  phase=Apple2PhasesVal(
61
- top_outer=f"{phase:.6f}",
62
- top_inner=f"{0.0:.6f}",
63
- btm_inner=f"{phase:.6f}",
64
- btm_outer=f"{0.0:.6f}",
61
+ top_outer=phase,
62
+ top_inner=0.0,
63
+ btm_inner=phase,
64
+ btm_outer=0.0,
65
65
  ),
66
66
  )