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.
- {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/METADATA +1 -1
- {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/RECORD +62 -51
- {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/WHEEL +1 -1
- dodal/_version.py +2 -2
- dodal/beamlines/__init__.py +0 -1
- dodal/beamlines/b07.py +2 -6
- dodal/beamlines/b07_1.py +1 -3
- dodal/beamlines/i03.py +12 -15
- dodal/beamlines/i04.py +48 -16
- dodal/beamlines/i09.py +1 -3
- dodal/beamlines/i09_1.py +1 -3
- dodal/beamlines/i23.py +17 -1
- dodal/beamlines/p38.py +1 -1
- dodal/beamlines/p60.py +2 -6
- dodal/beamlines/p99.py +48 -4
- dodal/common/beamlines/beamline_parameters.py +1 -2
- dodal/common/data_util.py +4 -0
- dodal/devices/aperturescatterguard.py +47 -47
- dodal/devices/current_amplifiers/struck_scaler_counter.py +1 -1
- dodal/devices/diamond_filter.py +5 -17
- dodal/devices/eiger.py +1 -1
- dodal/devices/electron_analyser/__init__.py +8 -0
- dodal/devices/electron_analyser/abstract/__init__.py +28 -0
- dodal/devices/electron_analyser/abstract/base_detector.py +210 -0
- dodal/devices/electron_analyser/abstract/base_driver_io.py +121 -0
- dodal/devices/electron_analyser/{abstract_region.py → abstract/base_region.py} +2 -9
- dodal/devices/electron_analyser/specs/__init__.py +11 -0
- dodal/devices/electron_analyser/specs/detector.py +29 -0
- dodal/devices/electron_analyser/specs/driver_io.py +64 -0
- dodal/devices/electron_analyser/{specs_region.py → specs/region.py} +1 -1
- dodal/devices/electron_analyser/types.py +6 -0
- dodal/devices/electron_analyser/util.py +13 -0
- dodal/devices/electron_analyser/vgscienta/__init__.py +12 -0
- dodal/devices/electron_analyser/vgscienta/detector.py +36 -0
- dodal/devices/electron_analyser/vgscienta/driver_io.py +39 -0
- dodal/devices/electron_analyser/{vgscienta_region.py → vgscienta/region.py} +1 -1
- dodal/devices/fast_grid_scan.py +7 -9
- dodal/devices/i03/__init__.py +3 -0
- dodal/devices/i04/__init__.py +3 -0
- dodal/devices/i04/constants.py +9 -0
- dodal/devices/i04/murko_results.py +195 -0
- dodal/devices/i10/diagnostics.py +9 -61
- dodal/devices/i24/focus_mirrors.py +9 -13
- dodal/devices/i24/pilatus_metadata.py +9 -9
- dodal/devices/i24/pmac.py +19 -14
- dodal/devices/{i03 → mx_phase1}/beamstop.py +6 -12
- dodal/devices/oav/oav_calculations.py +2 -2
- dodal/devices/oav/oav_detector.py +32 -22
- dodal/devices/oav/utils.py +2 -2
- dodal/devices/p99/andor2_point.py +41 -0
- dodal/devices/positioner.py +49 -0
- dodal/devices/tetramm.py +5 -2
- dodal/devices/util/adjuster_plans.py +1 -1
- dodal/devices/zebra/zebra_constants_mapping.py +1 -1
- dodal/devices/zocalo/__init__.py +0 -3
- dodal/devices/zocalo/zocalo_results.py +6 -32
- dodal/log.py +14 -14
- dodal/plan_stubs/electron_analyser/__init__.py +3 -0
- dodal/plan_stubs/electron_analyser/{configure_controller.py → configure_driver.py} +30 -18
- dodal/common/signal_utils.py +0 -88
- dodal/devices/electron_analyser/abstract_analyser_io.py +0 -47
- dodal/devices/electron_analyser/specs_analyser_io.py +0 -19
- dodal/devices/electron_analyser/vgscienta_analyser_io.py +0 -26
- dodal/devices/logging_ophyd_device.py +0 -17
- {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/entry_points.txt +0 -0
- {dls_dodal-1.46.0.dist-info → dls_dodal-1.47.0.dist-info}/licenses/LICENSE +0 -0
- {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
|
|
@@ -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.
|
|
7
|
+
from dodal.devices.electron_analyser.abstract.base_region import (
|
|
8
8
|
AbstractBaseRegion,
|
|
9
9
|
AbstractBaseSequence,
|
|
10
10
|
JavaToPythonModel,
|
dodal/devices/fast_grid_scan.py
CHANGED
|
@@ -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 =
|
|
207
|
-
|
|
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
|
-
|
|
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
|
dodal/devices/i03/__init__.py
CHANGED
|
@@ -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
|
+
)
|