dls-dodal 1.46.0__py3-none-any.whl → 1.47.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 (67) hide show
  1. {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/METADATA +1 -1
  2. {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/RECORD +62 -51
  3. {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/WHEEL +1 -1
  4. dodal/_version.py +2 -2
  5. dodal/beamlines/__init__.py +0 -1
  6. dodal/beamlines/b07.py +2 -6
  7. dodal/beamlines/b07_1.py +1 -3
  8. dodal/beamlines/i03.py +12 -15
  9. dodal/beamlines/i04.py +48 -16
  10. dodal/beamlines/i09.py +1 -3
  11. dodal/beamlines/i09_1.py +1 -3
  12. dodal/beamlines/i23.py +17 -1
  13. dodal/beamlines/p38.py +1 -1
  14. dodal/beamlines/p60.py +2 -6
  15. dodal/beamlines/p99.py +48 -4
  16. dodal/common/beamlines/beamline_parameters.py +1 -2
  17. dodal/common/data_util.py +4 -0
  18. dodal/devices/aperturescatterguard.py +47 -47
  19. dodal/devices/current_amplifiers/struck_scaler_counter.py +1 -1
  20. dodal/devices/diamond_filter.py +5 -17
  21. dodal/devices/eiger.py +1 -1
  22. dodal/devices/electron_analyser/__init__.py +8 -0
  23. dodal/devices/electron_analyser/abstract/__init__.py +28 -0
  24. dodal/devices/electron_analyser/abstract/base_detector.py +210 -0
  25. dodal/devices/electron_analyser/abstract/base_driver_io.py +121 -0
  26. dodal/devices/electron_analyser/{abstract_region.py → abstract/base_region.py} +2 -9
  27. dodal/devices/electron_analyser/specs/__init__.py +11 -0
  28. dodal/devices/electron_analyser/specs/detector.py +29 -0
  29. dodal/devices/electron_analyser/specs/driver_io.py +64 -0
  30. dodal/devices/electron_analyser/{specs_region.py → specs/region.py} +1 -1
  31. dodal/devices/electron_analyser/types.py +6 -0
  32. dodal/devices/electron_analyser/util.py +13 -0
  33. dodal/devices/electron_analyser/vgscienta/__init__.py +12 -0
  34. dodal/devices/electron_analyser/vgscienta/detector.py +36 -0
  35. dodal/devices/electron_analyser/vgscienta/driver_io.py +39 -0
  36. dodal/devices/electron_analyser/{vgscienta_region.py → vgscienta/region.py} +1 -1
  37. dodal/devices/fast_grid_scan.py +7 -9
  38. dodal/devices/i03/__init__.py +3 -0
  39. dodal/devices/i04/__init__.py +3 -0
  40. dodal/devices/i04/constants.py +9 -0
  41. dodal/devices/i04/murko_results.py +195 -0
  42. dodal/devices/i10/diagnostics.py +9 -61
  43. dodal/devices/i24/focus_mirrors.py +9 -13
  44. dodal/devices/i24/pilatus_metadata.py +9 -9
  45. dodal/devices/i24/pmac.py +19 -14
  46. dodal/devices/{i03 → mx_phase1}/beamstop.py +6 -12
  47. dodal/devices/oav/oav_calculations.py +2 -2
  48. dodal/devices/oav/oav_detector.py +32 -22
  49. dodal/devices/oav/utils.py +2 -2
  50. dodal/devices/p99/andor2_point.py +41 -0
  51. dodal/devices/positioner.py +49 -0
  52. dodal/devices/tetramm.py +5 -2
  53. dodal/devices/util/adjuster_plans.py +1 -1
  54. dodal/devices/zebra/zebra_constants_mapping.py +1 -1
  55. dodal/devices/zocalo/__init__.py +0 -3
  56. dodal/devices/zocalo/zocalo_results.py +6 -32
  57. dodal/log.py +14 -14
  58. dodal/plan_stubs/electron_analyser/__init__.py +3 -0
  59. dodal/plan_stubs/electron_analyser/{configure_controller.py → configure_driver.py} +30 -18
  60. dodal/common/signal_utils.py +0 -88
  61. dodal/devices/electron_analyser/abstract_analyser_io.py +0 -47
  62. dodal/devices/electron_analyser/specs_analyser_io.py +0 -19
  63. dodal/devices/electron_analyser/vgscienta_analyser_io.py +0 -26
  64. dodal/devices/logging_ophyd_device.py +0 -17
  65. {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/entry_points.txt +0 -0
  66. {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/licenses/LICENSE +0 -0
  67. {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,121 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import TypeVar
3
+
4
+ import numpy as np
5
+ from ophyd_async.core import (
6
+ Array1D,
7
+ SignalR,
8
+ StandardReadable,
9
+ StandardReadableFormat,
10
+ derived_signal_r,
11
+ soft_signal_rw,
12
+ )
13
+ from ophyd_async.epics.adcore import ADBaseIO
14
+ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
15
+
16
+ from dodal.devices.electron_analyser.types import EnergyMode
17
+ from dodal.devices.electron_analyser.util import to_binding_energy
18
+
19
+
20
+ class AbstractAnalyserDriverIO(ABC, StandardReadable, ADBaseIO):
21
+ """
22
+ Generic device to configure electron analyser with new region settings.
23
+ Electron analysers should inherit from this class for further specialisation.
24
+ """
25
+
26
+ def __init__(self, prefix: str, name: str = "") -> None:
27
+ with self.add_children_as_readables():
28
+ self.image = epics_signal_r(Array1D[np.float64], prefix + "IMAGE")
29
+ self.spectrum = epics_signal_r(Array1D[np.float64], prefix + "INT_SPECTRUM")
30
+ self.total_intensity = derived_signal_r(
31
+ self._calculate_total_intensity, spectrum=self.spectrum
32
+ )
33
+ self.excitation_energy = soft_signal_rw(float, initial_value=0, units="eV")
34
+
35
+ with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
36
+ # Used for setting up region data acquisition.
37
+ self.region_name = soft_signal_rw(str, initial_value="null")
38
+ self.energy_mode = soft_signal_rw(
39
+ EnergyMode, initial_value=EnergyMode.KINETIC
40
+ )
41
+ self.low_energy = epics_signal_rw(float, prefix + "LOW_ENERGY")
42
+ self.high_energy = epics_signal_rw(float, prefix + "HIGH_ENERGY")
43
+ self.slices = epics_signal_rw(int, prefix + "SLICES")
44
+ self.lens_mode = epics_signal_rw(str, prefix + "LENS_MODE")
45
+ self.pass_energy = epics_signal_rw(
46
+ self.pass_energy_type, prefix + "PASS_ENERGY"
47
+ )
48
+ self.energy_step = epics_signal_rw(float, prefix + "STEP_SIZE")
49
+ self.iterations = epics_signal_rw(int, prefix + "NumExposures")
50
+ self.acquisition_mode = epics_signal_rw(str, prefix + "ACQ_MODE")
51
+
52
+ # Read once per scan after data acquired
53
+ self.energy_axis = self._create_energy_axis_signal(prefix)
54
+ self.binding_energy_axis = derived_signal_r(
55
+ self._calculate_binding_energy_axis,
56
+ "eV",
57
+ energy_axis=self.energy_axis,
58
+ excitation_energy=self.excitation_energy,
59
+ energy_mode=self.energy_mode,
60
+ )
61
+ self.angle_axis = self._create_angle_axis_signal(prefix)
62
+ self.step_time = epics_signal_r(float, prefix + "AcquireTime")
63
+ self.total_steps = epics_signal_r(int, prefix + "TOTAL_POINTS_RBV")
64
+ self.total_time = derived_signal_r(
65
+ self._calculate_total_time,
66
+ "s",
67
+ total_steps=self.total_steps,
68
+ step_time=self.step_time,
69
+ iterations=self.iterations,
70
+ )
71
+
72
+ super().__init__(prefix=prefix, name=name)
73
+
74
+ @abstractmethod
75
+ def _create_angle_axis_signal(self, prefix: str) -> SignalR[Array1D[np.float64]]:
76
+ """
77
+ The signal that defines the angle axis. Depends on analyser model.
78
+ """
79
+
80
+ @abstractmethod
81
+ def _create_energy_axis_signal(self, prefix: str) -> SignalR[Array1D[np.float64]]:
82
+ """
83
+ The signal that defines the energy axis. Depends on analyser model.
84
+ """
85
+
86
+ def _calculate_binding_energy_axis(
87
+ self,
88
+ energy_axis: Array1D[np.float64],
89
+ excitation_energy: float,
90
+ energy_mode: EnergyMode,
91
+ ) -> Array1D[np.float64]:
92
+ is_binding = energy_mode == EnergyMode.BINDING
93
+ return np.array(
94
+ [
95
+ to_binding_energy(i_energy_axis, EnergyMode.KINETIC, excitation_energy)
96
+ if is_binding
97
+ else i_energy_axis
98
+ for i_energy_axis in energy_axis
99
+ ]
100
+ )
101
+
102
+ def _calculate_total_time(
103
+ self, total_steps: int, step_time: float, iterations: int
104
+ ) -> float:
105
+ return total_steps * step_time * iterations
106
+
107
+ def _calculate_total_intensity(self, spectrum: Array1D[np.float64]) -> float:
108
+ return float(np.sum(spectrum, dtype=np.float64))
109
+
110
+ @property
111
+ @abstractmethod
112
+ def pass_energy_type(self) -> type:
113
+ """
114
+ Return the type the pass_energy should be. Each one is unfortunately different
115
+ for the underlying analyser software and cannot be changed on epics side.
116
+ """
117
+
118
+
119
+ TAbstractAnalyserDriverIO = TypeVar(
120
+ "TAbstractAnalyserDriverIO", bound=AbstractAnalyserDriverIO
121
+ )
@@ -1,11 +1,12 @@
1
1
  import re
2
2
  from abc import ABC
3
3
  from collections.abc import Callable
4
- from enum import Enum
5
4
  from typing import Generic, TypeVar
6
5
 
7
6
  from pydantic import BaseModel, Field, model_validator
8
7
 
8
+ from dodal.devices.electron_analyser.types import EnergyMode
9
+
9
10
 
10
11
  def java_to_python_case(java_str: str) -> str:
11
12
  """
@@ -42,11 +43,6 @@ def energy_mode_validation(data: dict) -> dict:
42
43
  return data
43
44
 
44
45
 
45
- class EnergyMode(str, Enum):
46
- KINETIC = "Kinetic"
47
- BINDING = "Binding"
48
-
49
-
50
46
  class AbstractBaseRegion(ABC, JavaToPythonModel):
51
47
  """
52
48
  Generic region model that holds the data. Specialised region models should inherit
@@ -73,9 +69,6 @@ class AbstractBaseRegion(ABC, JavaToPythonModel):
73
69
  def is_kinetic_energy(self) -> bool:
74
70
  return self.energy_mode == EnergyMode.KINETIC
75
71
 
76
- def to_kinetic_energy(self, value: float, excitation_energy: float) -> float:
77
- return value if self.is_binding_energy() else excitation_energy - value
78
-
79
72
  @model_validator(mode="before")
80
73
  @classmethod
81
74
  def before_validation(cls, data: dict) -> dict:
@@ -0,0 +1,11 @@
1
+ from .detector import SpecsDetector, SpecsRegionDetector
2
+ from .driver_io import SpecsAnalyserDriverIO
3
+ from .region import SpecsRegion, SpecsSequence
4
+
5
+ __all__ = [
6
+ "SpecsDetector",
7
+ "SpecsRegionDetector",
8
+ "SpecsAnalyserDriverIO",
9
+ "SpecsRegion",
10
+ "SpecsSequence",
11
+ ]
@@ -0,0 +1,29 @@
1
+ from dodal.devices.electron_analyser.abstract.base_detector import (
2
+ AbstractElectronAnalyserDetector,
3
+ AbstractElectronAnalyserRegionDetector,
4
+ )
5
+ from dodal.devices.electron_analyser.specs.driver_io import SpecsAnalyserDriverIO
6
+ from dodal.devices.electron_analyser.specs.region import SpecsRegion, SpecsSequence
7
+
8
+
9
+ class SpecsRegionDetector(
10
+ AbstractElectronAnalyserRegionDetector[SpecsAnalyserDriverIO, SpecsRegion]
11
+ ):
12
+ def configure_region(self):
13
+ # ToDo - Need to move configure plans to here and rewrite tests
14
+ pass
15
+
16
+
17
+ class SpecsDetector(
18
+ AbstractElectronAnalyserDetector[SpecsAnalyserDriverIO, SpecsSequence, SpecsRegion]
19
+ ):
20
+ def __init__(self, prefix: str, name: str):
21
+ super().__init__(prefix, name, SpecsSequence)
22
+
23
+ def _create_driver(self, prefix: str) -> SpecsAnalyserDriverIO:
24
+ return SpecsAnalyserDriverIO(prefix, "driver")
25
+
26
+ def _create_region_detector(
27
+ self, driver: SpecsAnalyserDriverIO, region: SpecsRegion
28
+ ) -> SpecsRegionDetector:
29
+ return SpecsRegionDetector(self.name, driver, region)
@@ -0,0 +1,64 @@
1
+ import numpy as np
2
+ from ophyd_async.core import Array1D, SignalR, StandardReadableFormat, derived_signal_r
3
+ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
4
+
5
+ from dodal.devices.electron_analyser.abstract.base_driver_io import (
6
+ AbstractAnalyserDriverIO,
7
+ )
8
+
9
+
10
+ class SpecsAnalyserDriverIO(AbstractAnalyserDriverIO):
11
+ def __init__(self, prefix: str, name: str = "") -> None:
12
+ with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
13
+ # Used for setting up region data acquisition.
14
+ self.psu_mode = epics_signal_rw(str, prefix + "SCAN_RANGE")
15
+ self.snapshot_values = epics_signal_rw(int, prefix + "VALUES")
16
+ self.centre_energy = epics_signal_rw(float, prefix + "KINETIC_ENERGY")
17
+
18
+ # Used to read detector data after acqusition.
19
+ self.min_angle_axis = epics_signal_r(float, prefix + "Y_MIN_RBV")
20
+ self.max_angle_axis = epics_signal_r(float, prefix + "Y_MAX_RBV")
21
+
22
+ super().__init__(prefix, name)
23
+
24
+ def _create_angle_axis_signal(self, prefix: str) -> SignalR[Array1D[np.float64]]:
25
+ angle_axis = derived_signal_r(
26
+ self._calculate_angle_axis,
27
+ min_angle=self.min_angle_axis,
28
+ max_angle=self.max_angle_axis,
29
+ slices=self.slices,
30
+ )
31
+ return angle_axis
32
+
33
+ def _calculate_angle_axis(
34
+ self, min_angle: float, max_angle: float, slices: int
35
+ ) -> Array1D[np.float64]:
36
+ # SPECS returns the extreme edges of the range, not the centre of the pixels
37
+ width = (max_angle - min_angle) / slices
38
+ offset = width / 2
39
+
40
+ axis = np.array([min_angle + offset + i * width for i in range(slices)])
41
+ return axis
42
+
43
+ def _create_energy_axis_signal(self, prefix: str) -> SignalR[Array1D[np.float64]]:
44
+ energy_axis = derived_signal_r(
45
+ self._calculate_energy_axis,
46
+ "eV",
47
+ min_energy=self.low_energy,
48
+ max_energy=self.high_energy,
49
+ total_points_iterations=self.slices,
50
+ )
51
+ return energy_axis
52
+
53
+ def _calculate_energy_axis(
54
+ self, min_energy: float, max_energy: float, total_points_iterations: int
55
+ ) -> Array1D[np.float64]:
56
+ # Note: Don't use the energy step because of the case where the step doesn't
57
+ # exactly fill the range
58
+ step = (max_energy - min_energy) / (total_points_iterations - 1)
59
+ axis = np.array([min_energy + i * step for i in range(total_points_iterations)])
60
+ return axis
61
+
62
+ @property
63
+ def pass_energy_type(self) -> type:
64
+ return float
@@ -1,6 +1,6 @@
1
1
  from pydantic import Field
2
2
 
3
- from dodal.devices.electron_analyser.abstract_region import (
3
+ from dodal.devices.electron_analyser.abstract.base_region import (
4
4
  AbstractBaseRegion,
5
5
  AbstractBaseSequence,
6
6
  )
@@ -0,0 +1,6 @@
1
+ from ophyd_async.core import StrictEnum
2
+
3
+
4
+ class EnergyMode(StrictEnum):
5
+ KINETIC = "Kinetic"
6
+ BINDING = "Binding"
@@ -0,0 +1,13 @@
1
+ from dodal.devices.electron_analyser.types import EnergyMode
2
+
3
+
4
+ def to_kinetic_energy(
5
+ value: float, value_mode: EnergyMode, excitation_energy: float
6
+ ) -> float:
7
+ return value if value_mode == EnergyMode.KINETIC else excitation_energy - value
8
+
9
+
10
+ def to_binding_energy(
11
+ value: float, value_mode: EnergyMode, excitation_energy: float
12
+ ) -> float:
13
+ return value if value_mode == EnergyMode.BINDING else excitation_energy - value
@@ -0,0 +1,12 @@
1
+ from .detector import VGScientaDetector, VGScientaRegionDetector
2
+ from .driver_io import VGScientaAnalyserDriverIO
3
+ from .region import VGScientaExcitationEnergySource, VGScientaRegion, VGScientaSequence
4
+
5
+ __all__ = [
6
+ "VGScientaDetector",
7
+ "VGScientaRegionDetector",
8
+ "VGScientaAnalyserDriverIO",
9
+ "VGScientaExcitationEnergySource",
10
+ "VGScientaRegion",
11
+ "VGScientaSequence",
12
+ ]
@@ -0,0 +1,36 @@
1
+ from dodal.devices.electron_analyser.abstract.base_detector import (
2
+ AbstractElectronAnalyserDetector,
3
+ AbstractElectronAnalyserRegionDetector,
4
+ )
5
+ from dodal.devices.electron_analyser.vgscienta.driver_io import (
6
+ VGScientaAnalyserDriverIO,
7
+ )
8
+ from dodal.devices.electron_analyser.vgscienta.region import (
9
+ VGScientaRegion,
10
+ VGScientaSequence,
11
+ )
12
+
13
+
14
+ class VGScientaRegionDetector(
15
+ AbstractElectronAnalyserRegionDetector[VGScientaAnalyserDriverIO, VGScientaRegion]
16
+ ):
17
+ def configure_region(self):
18
+ # ToDo - Need to move configure plans to here and rewrite tests
19
+ pass
20
+
21
+
22
+ class VGScientaDetector(
23
+ AbstractElectronAnalyserDetector[
24
+ VGScientaAnalyserDriverIO, VGScientaSequence, VGScientaRegion
25
+ ]
26
+ ):
27
+ def __init__(self, prefix: str, name: str):
28
+ super().__init__(prefix, name, VGScientaSequence)
29
+
30
+ def _create_driver(self, prefix: str) -> VGScientaAnalyserDriverIO:
31
+ return VGScientaAnalyserDriverIO(prefix, "driver")
32
+
33
+ def _create_region_detector(
34
+ self, driver: VGScientaAnalyserDriverIO, region: VGScientaRegion
35
+ ) -> VGScientaRegionDetector:
36
+ return VGScientaRegionDetector(self.name, driver, region)
@@ -0,0 +1,39 @@
1
+ import numpy as np
2
+ from ophyd_async.core import Array1D, SignalR, StandardReadableFormat, soft_signal_rw
3
+ from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
4
+
5
+ from dodal.devices.electron_analyser.abstract.base_driver_io import (
6
+ AbstractAnalyserDriverIO,
7
+ )
8
+ from dodal.devices.electron_analyser.vgscienta.region import (
9
+ DetectorMode,
10
+ )
11
+
12
+
13
+ class VGScientaAnalyserDriverIO(AbstractAnalyserDriverIO):
14
+ def __init__(self, prefix: str, name: str = "") -> None:
15
+ with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
16
+ self.excitation_energy_source = soft_signal_rw(str, initial_value=None)
17
+ # Used for setting up region data acquisition.
18
+ self.centre_energy = epics_signal_rw(float, prefix + "CENTRE_ENERGY")
19
+ self.first_x_channel = epics_signal_rw(int, prefix + "MinX")
20
+ self.first_y_channel = epics_signal_rw(int, prefix + "MinY")
21
+ self.x_channel_size = epics_signal_rw(int, prefix + "SizeX")
22
+ self.y_channel_size = epics_signal_rw(int, prefix + "SizeY")
23
+ self.detector_mode = epics_signal_rw(DetectorMode, prefix + "DETECTOR_MODE")
24
+
25
+ with self.add_children_as_readables():
26
+ # Used to read detector data after acqusition.
27
+ self.external_io = epics_signal_r(Array1D[np.float64], prefix + "EXTIO")
28
+
29
+ super().__init__(prefix, name)
30
+
31
+ def _create_energy_axis_signal(self, prefix: str) -> SignalR[Array1D[np.float64]]:
32
+ return epics_signal_r(Array1D[np.float64], prefix + "X_SCALE_RBV")
33
+
34
+ def _create_angle_axis_signal(self, prefix: str) -> SignalR[Array1D[np.float64]]:
35
+ return epics_signal_r(Array1D[np.float64], prefix + "Y_SCALE_RBV")
36
+
37
+ @property
38
+ def pass_energy_type(self) -> type:
39
+ return str
@@ -4,7 +4,7 @@ from enum import Enum
4
4
  from ophyd_async.core import StrictEnum
5
5
  from pydantic import Field
6
6
 
7
- from dodal.devices.electron_analyser.abstract_region import (
7
+ from dodal.devices.electron_analyser.abstract.base_region import (
8
8
  AbstractBaseRegion,
9
9
  AbstractBaseSequence,
10
10
  JavaToPythonModel,
@@ -12,6 +12,7 @@ from ophyd_async.core import (
12
12
  Signal,
13
13
  SignalRW,
14
14
  StandardReadable,
15
+ derived_signal_r,
15
16
  wait_for_value,
16
17
  )
17
18
  from ophyd_async.epics.core import (
@@ -23,7 +24,6 @@ from ophyd_async.epics.core import (
23
24
  from pydantic import field_validator
24
25
  from pydantic.dataclasses import dataclass
25
26
 
26
- from dodal.common.signal_utils import create_r_hardware_backed_soft_signal
27
27
  from dodal.log import LOGGER
28
28
  from dodal.parameters.experiment_parameter_base import AbstractExperimentWithBeamParams
29
29
 
@@ -203,8 +203,11 @@ class FastGridScanCommon(StandardReadable, Flyable, ABC, Generic[ParamType]):
203
203
  self.stop_cmd = epics_signal_x(f"{prefix}STOP.PROC")
204
204
  self.status = epics_signal_r(int, f"{prefix}SCAN_STATUS")
205
205
 
206
- self.expected_images = create_r_hardware_backed_soft_signal(
207
- float, self._calculate_expected_images
206
+ self.expected_images = derived_signal_r(
207
+ self._calculate_expected_images,
208
+ x=self.x_steps,
209
+ y=self.y_steps,
210
+ z=self.z_steps,
208
211
  )
209
212
 
210
213
  self.motion_program = MotionProgram(smargon_prefix)
@@ -231,12 +234,7 @@ class FastGridScanCommon(StandardReadable, Flyable, ABC, Generic[ParamType]):
231
234
  }
232
235
  super().__init__(name)
233
236
 
234
- async def _calculate_expected_images(self):
235
- x, y, z = await asyncio.gather(
236
- self.x_steps.get_value(),
237
- self.y_steps.get_value(),
238
- self.z_steps.get_value(),
239
- )
237
+ def _calculate_expected_images(self, x: float, y: float, z: float) -> float:
240
238
  LOGGER.info(f"Reading num of images found {x, y, z} images in each axis")
241
239
  first_grid = x * y
242
240
  second_grid = x * z
@@ -0,0 +1,3 @@
1
+ from dodal.devices.mx_phase1.beamstop import Beamstop, BeamstopPositions
2
+
3
+ __all__ = ["Beamstop", "BeamstopPositions"]
@@ -0,0 +1,3 @@
1
+ from dodal.devices.mx_phase1.beamstop import Beamstop, BeamstopPositions
2
+
3
+ __all__ = ["Beamstop", "BeamstopPositions"]
@@ -0,0 +1,9 @@
1
+ import os
2
+ from dataclasses import dataclass
3
+
4
+
5
+ @dataclass(frozen=True)
6
+ class RedisConstants:
7
+ REDIS_HOST = os.environ.get("VALKEY_PROD_SVC_SERVICE_HOST", "test_redis")
8
+ REDIS_PASSWORD = os.environ.get("VALKEY_PASSWORD", "test_redis_password")
9
+ MURKO_REDIS_DB = 7
@@ -0,0 +1,195 @@
1
+ import json
2
+ import pickle
3
+ from collections import OrderedDict
4
+ from enum import Enum
5
+ from typing import TypedDict
6
+
7
+ import numpy as np
8
+ from bluesky.protocols import Stageable, Triggerable
9
+ from ophyd_async.core import (
10
+ AsyncStatus,
11
+ StandardReadable,
12
+ soft_signal_r_and_setter,
13
+ soft_signal_rw,
14
+ )
15
+ from redis.asyncio import StrictRedis
16
+
17
+ from dodal.devices.i04.constants import RedisConstants
18
+ from dodal.devices.oav.oav_calculations import (
19
+ calculate_beam_distance,
20
+ camera_coordinates_to_xyz_mm,
21
+ )
22
+ from dodal.log import LOGGER
23
+
24
+ MurkoResult = dict
25
+ FullMurkoResults = dict[str, list[MurkoResult]]
26
+
27
+
28
+ class MurkoMetadata(TypedDict):
29
+ zoom_percentage: float
30
+ microns_per_x_pixel: float
31
+ microns_per_y_pixel: float
32
+ beam_centre_i: int
33
+ beam_centre_j: int
34
+ sample_id: str
35
+ omega_angle: float
36
+ uuid: str
37
+
38
+
39
+ class Coord(Enum):
40
+ x = 0
41
+ y = 1
42
+ z = 2
43
+
44
+
45
+ class MurkoResultsDevice(StandardReadable, Triggerable, Stageable):
46
+ """Device that takes crystal centre coords from Murko and uses them to set the
47
+ x, y, z coordinate of the sample to be in line with the beam centre.
48
+ (x, z) coords can be read at 90°, and (x, y) at 180° (or the closest omega angle to
49
+ 90° and 180°). The average of the x values at these angles is taken, and sin(omega)z
50
+ and cosine(omega)y are taken to account for the rotation. This value is used to
51
+ calculate a number of mm the sample needs to move to be in line with the beam centre.
52
+ """
53
+
54
+ TIMEOUT_S = 2
55
+
56
+ def __init__(
57
+ self,
58
+ redis_host=RedisConstants.REDIS_HOST,
59
+ redis_password=RedisConstants.REDIS_PASSWORD,
60
+ redis_db=RedisConstants.MURKO_REDIS_DB,
61
+ name="",
62
+ ):
63
+ self.redis_client = StrictRedis(
64
+ host=redis_host,
65
+ password=redis_password,
66
+ db=redis_db,
67
+ )
68
+ self.pubsub = self.redis_client.pubsub()
69
+ self._last_omega = 0
70
+ self._last_result = None
71
+ self.sample_id = soft_signal_rw(str) # Should get from redis
72
+ self.coords = {"x": {}, "y": {}, "z": {}}
73
+ self.search_angles = OrderedDict(
74
+ [ # Angles to search and dimensions to gather at each angle
75
+ (90, ("x", "z")),
76
+ (180, ("x", "y")),
77
+ (270, ()), # Stop searching here
78
+ ]
79
+ )
80
+ self.angles_to_search = list(self.search_angles.keys())
81
+
82
+ with self.add_children_as_readables():
83
+ # Diffs from current x/y/z
84
+ self.x_mm, self._x_mm_setter = soft_signal_r_and_setter(float)
85
+ self.y_mm, self._y_mm_setter = soft_signal_r_and_setter(float)
86
+ self.z_mm, self._z_mm_setter = soft_signal_r_and_setter(float)
87
+ super().__init__(name=name)
88
+
89
+ @AsyncStatus.wrap
90
+ async def stage(self):
91
+ await self.pubsub.subscribe("murko-results")
92
+ self._x_mm_setter(0)
93
+ self._y_mm_setter(0)
94
+ self._z_mm_setter(0)
95
+
96
+ @AsyncStatus.wrap
97
+ async def unstage(self):
98
+ await self.pubsub.unsubscribe()
99
+
100
+ @AsyncStatus.wrap
101
+ async def trigger(self):
102
+ # Wait for results
103
+ sample_id = await self.sample_id.get_value()
104
+ final_message = None
105
+ while self.angles_to_search:
106
+ # waits here for next batch to be recieved
107
+ message = await self.pubsub.get_message(timeout=self.TIMEOUT_S)
108
+ if message is None: # No more messages to process
109
+ await self.process_batch(
110
+ final_message, sample_id
111
+ ) # Process final message again
112
+ break
113
+ await self.process_batch(message, sample_id)
114
+ final_message = message
115
+ x_values = list(self.coords["x"].values())
116
+ y_values = list(self.coords["y"].values())
117
+ z_values = list(self.coords["z"].values())
118
+ assert x_values, "No x values"
119
+ assert z_values, "No z values"
120
+ assert y_values, "No y values"
121
+ self._x_mm_setter(float(np.mean(x_values)))
122
+ self._y_mm_setter(float(np.mean(y_values)))
123
+ self._z_mm_setter(float(np.mean(z_values)))
124
+
125
+ async def process_batch(self, message: dict | None, sample_id: str):
126
+ if message and message["type"] == "message":
127
+ batch_results = pickle.loads(message["data"])
128
+ for results in batch_results:
129
+ LOGGER.info(f"Got {results} from redis")
130
+ for uuid, result in results.items():
131
+ metadata_str = await self.redis_client.hget( # type: ignore
132
+ f"murko:{sample_id}:metadata", uuid
133
+ )
134
+ if metadata_str and self.angles_to_search:
135
+ self.process_result(result, uuid, metadata_str)
136
+
137
+ def process_result(
138
+ self, result: dict, uuid: int, metadata_str: str
139
+ ) -> float | None:
140
+ metadata = MurkoMetadata(json.loads(metadata_str))
141
+ omega_angle = metadata["omega_angle"]
142
+ LOGGER.info(f"Got angle {omega_angle}")
143
+ # Find closest to next search angle
144
+ movement = self.get_coords_if_at_angle(metadata, result, omega_angle)
145
+ if movement is not None:
146
+ LOGGER.info(f"Using result {uuid}, {metadata_str}, {result}")
147
+ search_angle = self.angles_to_search.pop(0)
148
+ for coord in self.search_angles[search_angle]:
149
+ self.coords[coord][omega_angle] = movement[Coord[coord].value]
150
+ LOGGER.info(f"Found {coord} at {movement}, angle = {omega_angle}")
151
+ self._last_omega = omega_angle
152
+ self._last_result = result
153
+
154
+ def get_coords_if_at_angle(
155
+ self, metadata: MurkoMetadata, result: MurkoResult, omega: float
156
+ ) -> np.ndarray | None:
157
+ """Gets the 'most_likely_click' coordinates from Murko if omega or the last
158
+ omega are the closest angle to the search angle. Otherwise returns None.
159
+ """
160
+ search_angle = self.angles_to_search[0]
161
+ LOGGER.info(f"Compare {omega}, {search_angle}, {self._last_omega}")
162
+ if ( # if last omega is closest
163
+ abs(omega - search_angle) >= abs(self._last_omega - search_angle)
164
+ and self._last_result is not None
165
+ ):
166
+ closest_result = self._last_result
167
+ closest_omega = self._last_omega
168
+ elif omega - search_angle >= 0: # if this omega is closest
169
+ closest_result = result
170
+ closest_omega = omega
171
+ else:
172
+ return None
173
+ coords = closest_result[
174
+ "most_likely_click"
175
+ ] # As proportion from top, left of image
176
+ shape = closest_result["original_shape"] # Dimensions of image in pixels
177
+ # Murko returns coords as y, x
178
+ centre_px = (coords[1] * shape[1], coords[0] * shape[0])
179
+ LOGGER.info(
180
+ f"Using image taken at {closest_omega}, which found xtal at {centre_px}"
181
+ )
182
+
183
+ beam_dist_px = calculate_beam_distance(
184
+ (metadata["beam_centre_i"], metadata["beam_centre_j"]),
185
+ centre_px[0],
186
+ centre_px[1],
187
+ )
188
+
189
+ return camera_coordinates_to_xyz_mm(
190
+ beam_dist_px[0],
191
+ beam_dist_px[1],
192
+ closest_omega,
193
+ metadata["microns_per_x_pixel"],
194
+ metadata["microns_per_y_pixel"],
195
+ )