dls-dodal 1.44.0__py3-none-any.whl → 1.46.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 (61) hide show
  1. {dls_dodal-1.44.0.dist-info → dls_dodal-1.46.0.dist-info}/METADATA +2 -2
  2. {dls_dodal-1.44.0.dist-info → dls_dodal-1.46.0.dist-info}/RECORD +56 -46
  3. {dls_dodal-1.44.0.dist-info → dls_dodal-1.46.0.dist-info}/WHEEL +1 -1
  4. dodal/_version.py +2 -2
  5. dodal/beamlines/__init__.py +2 -0
  6. dodal/beamlines/b07.py +27 -0
  7. dodal/beamlines/b07_1.py +25 -0
  8. dodal/beamlines/i03.py +4 -4
  9. dodal/beamlines/i04.py +1 -1
  10. dodal/beamlines/i09.py +25 -0
  11. dodal/beamlines/i09_1.py +25 -0
  12. dodal/beamlines/i10.py +19 -35
  13. dodal/beamlines/i18.py +7 -4
  14. dodal/beamlines/i19_1.py +2 -1
  15. dodal/beamlines/i19_2.py +2 -1
  16. dodal/beamlines/i20_1.py +2 -1
  17. dodal/beamlines/i22.py +3 -3
  18. dodal/beamlines/i23.py +67 -2
  19. dodal/beamlines/p38.py +3 -3
  20. dodal/beamlines/p60.py +21 -0
  21. dodal/common/beamlines/beamline_utils.py +5 -0
  22. dodal/common/visit.py +1 -41
  23. dodal/devices/common_dcm.py +77 -0
  24. dodal/devices/detector/det_dist_to_beam_converter.py +16 -23
  25. dodal/devices/detector/detector.py +2 -1
  26. dodal/devices/electron_analyser/abstract_analyser_io.py +47 -0
  27. dodal/devices/electron_analyser/abstract_region.py +112 -0
  28. dodal/devices/electron_analyser/specs_analyser_io.py +19 -0
  29. dodal/devices/electron_analyser/specs_region.py +26 -0
  30. dodal/devices/electron_analyser/vgscienta_analyser_io.py +26 -0
  31. dodal/devices/electron_analyser/vgscienta_region.py +90 -0
  32. dodal/devices/{dcm.py → i03/dcm.py} +8 -12
  33. dodal/devices/{undulator_dcm.py → i03/undulator_dcm.py} +6 -4
  34. dodal/devices/i10/diagnostics.py +239 -0
  35. dodal/devices/i10/slits.py +93 -6
  36. dodal/devices/i13_1/merlin.py +3 -4
  37. dodal/devices/i13_1/merlin_controller.py +1 -1
  38. dodal/devices/i19/blueapi_device.py +102 -0
  39. dodal/devices/i19/shutter.py +5 -43
  40. dodal/devices/i22/dcm.py +10 -12
  41. dodal/devices/i24/dcm.py +8 -17
  42. dodal/devices/motors.py +21 -0
  43. dodal/devices/tetramm.py +3 -4
  44. dodal/devices/turbo_slit.py +10 -4
  45. dodal/devices/undulator.py +9 -7
  46. dodal/devices/util/adjuster_plans.py +1 -2
  47. dodal/devices/util/lookup_tables.py +38 -0
  48. dodal/devices/util/test_utils.py +1 -0
  49. dodal/devices/zebra/zebra.py +4 -0
  50. dodal/plan_stubs/data_session.py +10 -1
  51. dodal/plan_stubs/electron_analyser/configure_controller.py +80 -0
  52. dodal/plans/verify_undulator_gap.py +2 -2
  53. dodal/devices/electron_analyser/base_region.py +0 -64
  54. dodal/devices/electron_analyser/specs/specs_region.py +0 -24
  55. dodal/devices/electron_analyser/vgscienta/__init__.py +0 -0
  56. dodal/devices/electron_analyser/vgscienta/vgscienta_region.py +0 -77
  57. dodal/devices/util/motor_utils.py +0 -6
  58. {dls_dodal-1.44.0.dist-info → dls_dodal-1.46.0.dist-info}/entry_points.txt +0 -0
  59. {dls_dodal-1.44.0.dist-info → dls_dodal-1.46.0.dist-info}/licenses/LICENSE +0 -0
  60. {dls_dodal-1.44.0.dist-info → dls_dodal-1.46.0.dist-info}/top_level.txt +0 -0
  61. /dodal/{devices/electron_analyser/specs → plan_stubs/electron_analyser}/__init__.py +0 -0
dodal/beamlines/i23.py CHANGED
@@ -1,6 +1,24 @@
1
- from dodal.common.beamlines.beamline_utils import device_factory
1
+ from pathlib import Path
2
+
3
+ from ophyd_async.epics.adpilatus import PilatusDetector
4
+
5
+ from dodal.common.beamlines.beamline_utils import (
6
+ device_factory,
7
+ get_path_provider,
8
+ set_path_provider,
9
+ )
2
10
  from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline
11
+ from dodal.common.beamlines.device_helpers import HDF5_SUFFIX
12
+ from dodal.common.visit import LocalDirectoryServiceClient, StaticVisitPathProvider
13
+ from dodal.devices.motors import SixAxisGonio
3
14
  from dodal.devices.oav.pin_image_recognition import PinTipDetection
15
+ from dodal.devices.zebra.zebra import Zebra
16
+ from dodal.devices.zebra.zebra_constants_mapping import (
17
+ ZebraMapping,
18
+ ZebraSources,
19
+ ZebraTTLOutputs,
20
+ )
21
+ from dodal.devices.zebra.zebra_controlled_shutter import ZebraShutter
4
22
  from dodal.log import set_beamline as set_log_beamline
5
23
  from dodal.utils import BeamlinePrefix, get_beamline_name, get_hostname
6
24
 
@@ -8,8 +26,22 @@ BL = get_beamline_name("i23")
8
26
  set_log_beamline(BL)
9
27
  set_utils_beamline(BL)
10
28
 
29
+ set_path_provider(
30
+ StaticVisitPathProvider(
31
+ BL,
32
+ Path("/tmp"),
33
+ client=LocalDirectoryServiceClient(),
34
+ )
35
+ )
36
+
11
37
  PREFIX = BeamlinePrefix(BL)
12
38
 
39
+ I23_ZEBRA_MAPPING = ZebraMapping(
40
+ outputs=ZebraTTLOutputs(TTL_DETECTOR=1, TTL_SHUTTER=4),
41
+ sources=ZebraSources(),
42
+ AND_GATE_FOR_AUTO_SHUTTER=2,
43
+ )
44
+
13
45
 
14
46
  def _is_i23_machine():
15
47
  """
@@ -22,9 +54,42 @@ def _is_i23_machine():
22
54
 
23
55
  @device_factory(skip=lambda: not _is_i23_machine())
24
56
  def oav_pin_tip_detection() -> PinTipDetection:
25
- """Get the i23 OAV pin-tip detection device"""
57
+ """Get the i23 OAV pin-tip detection device."""
26
58
 
27
59
  return PinTipDetection(
28
60
  f"{PREFIX.beamline_prefix}-DI-OAV-01:",
29
61
  "pin_tip_detection",
30
62
  )
63
+
64
+
65
+ @device_factory()
66
+ def shutter() -> ZebraShutter:
67
+ """Get the i23 zebra controlled shutter."""
68
+ return ZebraShutter(f"{PREFIX.beamline_prefix}-EA-SHTR-01:", "shutter")
69
+
70
+
71
+ @device_factory()
72
+ def gonio() -> SixAxisGonio:
73
+ """Get the i23 goniometer"""
74
+ return SixAxisGonio(f"{PREFIX.beamline_prefix}-MO-GONIO-01:")
75
+
76
+
77
+ @device_factory()
78
+ def zebra() -> Zebra:
79
+ """Get the i23 zebra"""
80
+ return Zebra(
81
+ name="zebra",
82
+ prefix=f"{PREFIX.beamline_prefix}-EA-ZEBRA-01:ZEBRA:",
83
+ mapping=I23_ZEBRA_MAPPING,
84
+ )
85
+
86
+
87
+ @device_factory()
88
+ def pilatus() -> PilatusDetector:
89
+ """Get the i23 pilatus"""
90
+ return PilatusDetector(
91
+ prefix=f"{PREFIX.beamline_prefix}-EA-PILAT-01:",
92
+ path_provider=get_path_provider(),
93
+ drv_suffix="cam1:",
94
+ fileio_suffix=HDF5_SUFFIX,
95
+ )
dodal/beamlines/p38.py CHANGED
@@ -16,7 +16,7 @@ from dodal.common.crystal_metadata import (
16
16
  )
17
17
  from dodal.common.visit import LocalDirectoryServiceClient, StaticVisitPathProvider
18
18
  from dodal.devices.focusing_mirror import FocusingMirror
19
- from dodal.devices.i22.dcm import DoubleCrystalMonochromator
19
+ from dodal.devices.i22.dcm import DCM
20
20
  from dodal.devices.i22.fswitch import FSwitch
21
21
  from dodal.devices.linkam3 import Linkam3
22
22
  from dodal.devices.pressure_jump_cell import PressureJumpCell
@@ -143,8 +143,8 @@ def hfm() -> FocusingMirror:
143
143
 
144
144
 
145
145
  @device_factory(mock=True)
146
- def dcm() -> DoubleCrystalMonochromator:
147
- return DoubleCrystalMonochromator(
146
+ def dcm() -> DCM:
147
+ return DCM(
148
148
  temperature_prefix=f"{PREFIX.beamline_prefix}-DI-DCM-01:",
149
149
  crystal_1_metadata=make_crystal_metadata_from_material(
150
150
  MaterialsEnum.Si, (1, 1, 1)
dodal/beamlines/p60.py ADDED
@@ -0,0 +1,21 @@
1
+ from dodal.common.beamlines.beamline_utils import (
2
+ device_factory,
3
+ )
4
+ from dodal.common.beamlines.beamline_utils import set_beamline as set_utils_beamline
5
+ from dodal.devices.electron_analyser.vgscienta_analyser_io import (
6
+ VGScientaAnalyserDriverIO,
7
+ )
8
+ from dodal.log import set_beamline as set_log_beamline
9
+ from dodal.utils import BeamlinePrefix, get_beamline_name
10
+
11
+ BL = get_beamline_name("p60")
12
+ PREFIX = BeamlinePrefix(BL)
13
+ set_log_beamline(BL)
14
+ set_utils_beamline(BL)
15
+
16
+
17
+ @device_factory()
18
+ def analyser_driver() -> VGScientaAnalyserDriverIO:
19
+ return VGScientaAnalyserDriverIO(
20
+ name="analyser_driver", prefix=f"{PREFIX.beamline_prefix}-EA-DET-01:CAM:"
21
+ )
@@ -162,3 +162,8 @@ def set_path_provider(provider: PathProvider):
162
162
 
163
163
  def get_path_provider() -> PathProvider:
164
164
  return PATH_PROVIDER
165
+
166
+
167
+ def clear_path_provider() -> None:
168
+ global PATH_PROVIDER
169
+ del PATH_PROVIDER
dodal/common/visit.py CHANGED
@@ -3,8 +3,7 @@ from pathlib import Path
3
3
  from typing import Literal
4
4
 
5
5
  from aiohttp import ClientSession
6
- from event_model import RunStart
7
- from ophyd_async.core import FilenameProvider, PathInfo, PathProvider
6
+ from ophyd_async.core import FilenameProvider, PathInfo
8
7
  from pydantic import BaseModel
9
8
 
10
9
  from dodal.common.types import UpdatingPathProvider
@@ -151,42 +150,3 @@ class StaticVisitPathProvider(UpdatingPathProvider):
151
150
  return PathInfo(
152
151
  directory_path=self._root, filename=self._filename_provider(device_name)
153
152
  )
154
-
155
-
156
- DEFAULT_TEMPLATE = "{device_name}-{instrument}-{scan_id}"
157
-
158
-
159
- class StartDocumentPathProvider(PathProvider):
160
- """A PathProvider that sources from metadata in a RunStart document.
161
-
162
- This uses metadata from a RunStart document to determine file names and data session
163
- directories. The file naming defaults to "{device_name}-{instrument}-{scan_id}", so
164
- the file name is incremented by scan number. A template can be included in the
165
- StartDocument to allow for custom naming conventions.
166
-
167
- """
168
-
169
- def __init__(self) -> None:
170
- self._doc = {}
171
-
172
- def update_run(self, name: str, start_doc: RunStart) -> None:
173
- """Cache a start document.
174
-
175
- This can be plugged into the run engine's subscribe method.
176
- """
177
- if name == "start":
178
- self._doc = start_doc
179
-
180
- def __call__(self, device_name: str | None = None) -> PathInfo:
181
- """Returns the directory path and filename for a given data_session.
182
-
183
- The default template for file naming is: "{device_name}-{instrument}-{scan_id}"
184
- however, this can be changed by providing a template in the start document. For
185
- example: "template": "custom-{device_name}--{scan_id}".
186
-
187
- If you do not provide a data_session_directory it will default to "/tmp".
188
- """
189
- template = self._doc.get("template", DEFAULT_TEMPLATE)
190
- sub_path = template.format_map(self._doc | {"device_name": device_name})
191
- data_session_directory = Path(self._doc.get("data_session_directory", "/tmp"))
192
- return PathInfo(directory_path=data_session_directory, filename=sub_path)
@@ -0,0 +1,77 @@
1
+ from typing import Generic, TypeVar
2
+
3
+ from ophyd_async.core import (
4
+ StandardReadable,
5
+ )
6
+ from ophyd_async.epics.core import epics_signal_r
7
+ from ophyd_async.epics.motor import Motor
8
+
9
+
10
+ class StationaryCrystal(StandardReadable):
11
+ def __init__(self, prefix):
12
+ super().__init__(prefix)
13
+
14
+
15
+ class RollCrystal(StationaryCrystal):
16
+ def __init__(self, prefix):
17
+ with self.add_children_as_readables():
18
+ self.roll_in_mrad = Motor(prefix + "ROLL")
19
+ super().__init__(prefix)
20
+
21
+
22
+ class PitchAndRollCrystal(StationaryCrystal):
23
+ def __init__(self, prefix):
24
+ with self.add_children_as_readables():
25
+ self.pitch_in_mrad = Motor(prefix + "PITCH")
26
+ self.roll_in_mrad = Motor(prefix + "ROLL")
27
+ super().__init__(prefix)
28
+
29
+
30
+ Xtal_1 = TypeVar("Xtal_1", bound=StationaryCrystal)
31
+ Xtal_2 = TypeVar("Xtal_2", bound=StationaryCrystal)
32
+
33
+
34
+ class BaseDCM(StandardReadable, Generic[Xtal_1, Xtal_2]):
35
+ """
36
+ Common device for the double crystal monochromator (DCM), used to select the energy of the beam.
37
+
38
+ Features common across all DCM's should include virtual motors to set energy/wavelength and contain two crystals,
39
+ each of which can be movable. Some DCM's contain crystals with roll motors, and some contain crystals with roll and pitch motors.
40
+ This base device accounts for all combinations of this.
41
+
42
+ This device should act as a parent for beamline-specific DCM's, in which any other missing signals can be added.
43
+
44
+ Bluesky plans using DCM's should be typed to specify which types of crystals are required. For example, a plan
45
+ which only requires one crystal which can roll should be typed 'def my_plan(dcm: BaseDCM[RollCrystal, StationaryCrystal])`
46
+ """
47
+
48
+ def __init__(
49
+ self, prefix: str, xtal_1: type[Xtal_1], xtal_2: type[Xtal_2], name: str = ""
50
+ ) -> None:
51
+ with self.add_children_as_readables():
52
+ # Virtual motor PV's which set the physical motors so that the DCM produces requested
53
+ # wavelength/energy
54
+ self.energy_in_kev = Motor(prefix + "ENERGY")
55
+ self.wavelength_in_a = Motor(prefix + "WAVELENGTH")
56
+
57
+ # Real motors
58
+ self.bragg_in_degrees = Motor(prefix + "BRAGG")
59
+ # Offset ensures that the beam exits the DCM at the same point, regardless of energy.
60
+ self.offset_in_mm = Motor(prefix + "OFFSET")
61
+
62
+ self.crystal_metadata_d_spacing_a = epics_signal_r(
63
+ float, prefix + "DSPACING:RBV"
64
+ )
65
+
66
+ self._make_crystals(prefix, xtal_1, xtal_2)
67
+
68
+ super().__init__(name)
69
+
70
+ # Prefix convention is different depending on whether there are one or two controllable crystals
71
+ def _make_crystals(self, prefix: str, xtal_1: type[Xtal_1], xtal_2: type[Xtal_2]):
72
+ if StationaryCrystal not in [xtal_1, xtal_2]:
73
+ self.xtal_1 = xtal_1(f"{prefix}XTAL1:")
74
+ self.xtal_2 = xtal_2(f"{prefix}XTAL2:")
75
+ else:
76
+ self.xtal_1 = xtal_1(prefix)
77
+ self.xtal_2 = xtal_2(prefix)
@@ -1,6 +1,9 @@
1
1
  from enum import Enum
2
2
 
3
- from numpy import interp, loadtxt
3
+ from dodal.devices.util.lookup_tables import (
4
+ linear_extrapolation_lut,
5
+ parse_lookup_table,
6
+ )
4
7
 
5
8
 
6
9
  class Axis(Enum):
@@ -11,12 +14,20 @@ class Axis(Enum):
11
14
  class DetectorDistanceToBeamXYConverter:
12
15
  def __init__(self, lookup_file: str):
13
16
  self.lookup_file: str = lookup_file
14
- self.lookup_table_values: list = self.parse_table()
17
+ lookup_table_columns: list = parse_lookup_table(self.lookup_file)
18
+ self._d_to_x = linear_extrapolation_lut(
19
+ lookup_table_columns[0], lookup_table_columns[1]
20
+ )
21
+ self._d_to_y = linear_extrapolation_lut(
22
+ lookup_table_columns[0], lookup_table_columns[2]
23
+ )
15
24
 
16
25
  def get_beam_xy_from_det_dist(self, det_dist_mm: float, beam_axis: Axis) -> float:
17
- beam_axis_values = self.lookup_table_values[beam_axis.value]
18
- det_dist_array = self.lookup_table_values[0]
19
- return float(interp(det_dist_mm, det_dist_array, beam_axis_values))
26
+ return (
27
+ self._d_to_x(det_dist_mm)
28
+ if beam_axis == Axis.X_AXIS
29
+ else self._d_to_y(det_dist_mm)
30
+ )
20
31
 
21
32
  def get_beam_axis_pixels(
22
33
  self,
@@ -41,21 +52,3 @@ class DetectorDistanceToBeamXYConverter:
41
52
  return self.get_beam_axis_pixels(
42
53
  det_distance, image_size_pixels, det_dim, Axis.X_AXIS
43
54
  )
44
-
45
- def reload_lookup_table(self):
46
- self.lookup_table_values = self.parse_table()
47
-
48
- def parse_table(self) -> list:
49
- rows = loadtxt(self.lookup_file, delimiter=" ", comments=["#", "Units"])
50
- columns = list(zip(*rows, strict=False))
51
-
52
- return columns
53
-
54
- def __eq__(self, other):
55
- if not isinstance(other, DetectorDistanceToBeamXYConverter):
56
- return NotImplemented
57
- if self.lookup_file != other.lookup_file:
58
- return False
59
- if self.lookup_table_values != other.lookup_table_values:
60
- return False
61
- return True
@@ -1,4 +1,5 @@
1
1
  from enum import Enum, auto
2
+ from functools import cached_property
2
3
  from pathlib import Path
3
4
 
4
5
  from pydantic import BaseModel, Field, field_serializer, field_validator
@@ -49,7 +50,7 @@ class DetectorParams(BaseModel):
49
50
  False # Remove in https://github.com/DiamondLightSource/hyperion/issues/1395
50
51
  )
51
52
 
52
- @property
53
+ @cached_property
53
54
  def beam_xy_converter(self) -> DetectorDistanceToBeamXYConverter:
54
55
  return DetectorDistanceToBeamXYConverter(self.det_dist_to_beam_converter_path)
55
56
 
@@ -0,0 +1,47 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import TypeVar
3
+
4
+ from ophyd_async.core import StandardReadable
5
+ from ophyd_async.epics.core import epics_signal_rw
6
+
7
+ from dodal.devices.electron_analyser.abstract_region import EnergyMode
8
+
9
+
10
+ class AbstractAnalyserDriverIO(ABC, StandardReadable):
11
+ """
12
+ Generic device to configure electron analyser with new region settings.
13
+ Electron analysers should inherit from this class for further specialisation.
14
+ """
15
+
16
+ def __init__(self, prefix: str, name: str = "") -> None:
17
+ with self.add_children_as_readables():
18
+ self.low_energy = epics_signal_rw(float, prefix + "LOW_ENERGY")
19
+ self.high_energy = epics_signal_rw(float, prefix + "HIGH_ENERGY")
20
+ self.slices = epics_signal_rw(int, prefix + "SLICES")
21
+ self.lens_mode = epics_signal_rw(str, prefix + "LENS_MODE")
22
+ self.pass_energy = epics_signal_rw(
23
+ self.pass_energy_type, prefix + "PASS_ENERGY"
24
+ )
25
+ self.energy_step = epics_signal_rw(float, prefix + "STEP_SIZE")
26
+ self.iterations = epics_signal_rw(int, prefix + "NumExposures")
27
+ self.acquisition_mode = epics_signal_rw(str, prefix + "ACQ_MODE")
28
+
29
+ super().__init__(name)
30
+
31
+ def to_kinetic_energy(
32
+ self, value: float, excitation_energy: float, mode: EnergyMode
33
+ ) -> float:
34
+ return excitation_energy - value if mode == EnergyMode.BINDING else value
35
+
36
+ @property
37
+ @abstractmethod
38
+ def pass_energy_type(self) -> type:
39
+ """
40
+ Return the type the pass_energy should be. Each one is unfortunately different
41
+ for the underlying analyser software and cannot be changed on epics side.
42
+ """
43
+
44
+
45
+ TAbstractAnalyserDriverIO = TypeVar(
46
+ "TAbstractAnalyserDriverIO", bound=AbstractAnalyserDriverIO
47
+ )
@@ -0,0 +1,112 @@
1
+ import re
2
+ from abc import ABC
3
+ from collections.abc import Callable
4
+ from enum import Enum
5
+ from typing import Generic, TypeVar
6
+
7
+ from pydantic import BaseModel, Field, model_validator
8
+
9
+
10
+ def java_to_python_case(java_str: str) -> str:
11
+ """
12
+ Convert a camelCase Java-style string to a snake_case Python-style string.
13
+
14
+ :param java_str: The Java-style camelCase string.
15
+ :return: The Python-style snake_case string.
16
+ """
17
+ new_value = re.sub("(.)([A-Z][a-z]+)", r"\1_\2", java_str)
18
+ new_value = re.sub("([a-z0-9])([A-Z])", r"\1_\2", new_value).lower()
19
+ return new_value
20
+
21
+
22
+ def switch_case_validation(data: dict, f: Callable[[str], str]) -> dict:
23
+ return {f(key): value for key, value in data.items()}
24
+
25
+
26
+ class JavaToPythonModel(BaseModel):
27
+ @model_validator(mode="before")
28
+ @classmethod
29
+ def before_validation(cls, data: dict) -> dict:
30
+ data = switch_case_validation(data, java_to_python_case)
31
+ return data
32
+
33
+
34
+ def energy_mode_validation(data: dict) -> dict:
35
+ # Convert binding_energy to energy_mode to make base region more generic
36
+ if "binding_energy" in data:
37
+ is_binding_energy = data["binding_energy"]
38
+ del data["binding_energy"]
39
+ data["energy_mode"] = (
40
+ EnergyMode.BINDING if is_binding_energy else EnergyMode.KINETIC
41
+ )
42
+ return data
43
+
44
+
45
+ class EnergyMode(str, Enum):
46
+ KINETIC = "Kinetic"
47
+ BINDING = "Binding"
48
+
49
+
50
+ class AbstractBaseRegion(ABC, JavaToPythonModel):
51
+ """
52
+ Generic region model that holds the data. Specialised region models should inherit
53
+ this to extend functionality. All energy units are assumed to be in eV.
54
+ """
55
+
56
+ name: str = "New_region"
57
+ enabled: bool = False
58
+ slices: int = 1
59
+ iterations: int = 1
60
+ # These ones we need subclasses to provide default values
61
+ lens_mode: str
62
+ pass_energy: int
63
+ acquisition_mode: str
64
+ low_energy: float
65
+ high_energy: float
66
+ step_time: float
67
+ energy_step: float # in eV
68
+ energy_mode: EnergyMode = EnergyMode.KINETIC
69
+
70
+ def is_binding_energy(self) -> bool:
71
+ return self.energy_mode == EnergyMode.BINDING
72
+
73
+ def is_kinetic_energy(self) -> bool:
74
+ return self.energy_mode == EnergyMode.KINETIC
75
+
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
+ @model_validator(mode="before")
80
+ @classmethod
81
+ def before_validation(cls, data: dict) -> dict:
82
+ data = switch_case_validation(data, java_to_python_case)
83
+ return energy_mode_validation(data)
84
+
85
+
86
+ TAbstractBaseRegion = TypeVar("TAbstractBaseRegion", bound=AbstractBaseRegion)
87
+
88
+
89
+ class AbstractBaseSequence(ABC, JavaToPythonModel, Generic[TAbstractBaseRegion]):
90
+ """
91
+ Generic sequence model that holds the list of region data. Specialised sequence
92
+ models should inherit this to extend functionality and define type of region to
93
+ hold.
94
+ """
95
+
96
+ version: float = 0.1 # If file format changes within prod, increment this number!
97
+ regions: list[TAbstractBaseRegion] = Field(default_factory=lambda: [])
98
+
99
+ def get_enabled_regions(self) -> list[TAbstractBaseRegion]:
100
+ return [r for r in self.regions if r.enabled]
101
+
102
+ def get_region_names(self) -> list[str]:
103
+ return [r.name for r in self.regions]
104
+
105
+ def get_enabled_region_names(self) -> list[str]:
106
+ return [r.name for r in self.get_enabled_regions()]
107
+
108
+ def get_region_by_name(self, name: str) -> TAbstractBaseRegion | None:
109
+ return next((region for region in self.regions if region.name == name), None)
110
+
111
+
112
+ TAbstractBaseSequence = TypeVar("TAbstractBaseSequence", bound=AbstractBaseSequence)
@@ -0,0 +1,19 @@
1
+ from ophyd_async.epics.core import epics_signal_rw
2
+
3
+ from dodal.devices.electron_analyser.abstract_analyser_io import (
4
+ AbstractAnalyserDriverIO,
5
+ )
6
+
7
+
8
+ class SpecsAnalyserDriverIO(AbstractAnalyserDriverIO):
9
+ def __init__(self, prefix: str, name: str = "") -> None:
10
+ with self.add_children_as_readables():
11
+ self.psu_mode = epics_signal_rw(str, prefix + "SCAN_RANGE")
12
+ self.values = epics_signal_rw(int, prefix + "VALUES")
13
+ self.centre_energy = epics_signal_rw(float, prefix + "KINETIC_ENERGY")
14
+
15
+ super().__init__(prefix, name)
16
+
17
+ @property
18
+ def pass_energy_type(self) -> type:
19
+ return float
@@ -0,0 +1,26 @@
1
+ from pydantic import Field
2
+
3
+ from dodal.devices.electron_analyser.abstract_region import (
4
+ AbstractBaseRegion,
5
+ AbstractBaseSequence,
6
+ )
7
+
8
+
9
+ class SpecsRegion(AbstractBaseRegion):
10
+ # Override base class with defaults
11
+ lens_mode: str = "SmallArea"
12
+ pass_energy: int = 5
13
+ acquisition_mode: str = "Fixed Transmission"
14
+ low_energy: float = Field(default=800, alias="start_energy")
15
+ high_energy: float = Field(default=850, alias="end_energy")
16
+ step_time: float = Field(default=1.0, alias="exposure_time")
17
+ energy_step: float = Field(default=0.1, alias="step_energy")
18
+ # Specific to this class
19
+ values: int = 1
20
+ centre_energy: float = 0
21
+ psu_mode: str = "1.5keV"
22
+ estimated_time_in_ms: float = 0
23
+
24
+
25
+ class SpecsSequence(AbstractBaseSequence[SpecsRegion]):
26
+ regions: list[SpecsRegion] = Field(default_factory=lambda: [])
@@ -0,0 +1,26 @@
1
+ from ophyd_async.epics.core import epics_signal_rw
2
+
3
+ from dodal.devices.electron_analyser.abstract_analyser_io import (
4
+ AbstractAnalyserDriverIO,
5
+ )
6
+ from dodal.devices.electron_analyser.vgscienta_region import (
7
+ DetectorMode,
8
+ )
9
+
10
+
11
+ class VGScientaAnalyserDriverIO(AbstractAnalyserDriverIO):
12
+ def __init__(self, prefix: str, name: str = "") -> None:
13
+ with self.add_children_as_readables():
14
+ self.centre_energy = epics_signal_rw(float, prefix + "CENTRE_ENERGY")
15
+ self.first_x_channel = epics_signal_rw(int, prefix + "MinX")
16
+ self.first_y_channel = epics_signal_rw(int, prefix + "MinY")
17
+ self.x_channel_size = epics_signal_rw(int, prefix + "SizeX")
18
+ self.y_channel_size = epics_signal_rw(int, prefix + "SizeY")
19
+ self.detector_mode = epics_signal_rw(DetectorMode, prefix + "DETECTOR_MODE")
20
+ self.image_mode = epics_signal_rw(str, prefix + "ImageMode")
21
+
22
+ super().__init__(prefix, name)
23
+
24
+ @property
25
+ def pass_energy_type(self) -> type:
26
+ return str
@@ -0,0 +1,90 @@
1
+ import uuid
2
+ from enum import Enum
3
+
4
+ from ophyd_async.core import StrictEnum
5
+ from pydantic import Field
6
+
7
+ from dodal.devices.electron_analyser.abstract_region import (
8
+ AbstractBaseRegion,
9
+ AbstractBaseSequence,
10
+ JavaToPythonModel,
11
+ )
12
+
13
+
14
+ class Status(str, Enum):
15
+ READY = "Ready"
16
+ RUNNING = "Running"
17
+ COMPLETED = "Completed"
18
+ INVALID = "Invalid"
19
+ ABORTED = "Aborted"
20
+
21
+
22
+ class DetectorMode(StrictEnum):
23
+ ADC = "ADC"
24
+ PULSE_COUNTING = "Pulse Counting"
25
+
26
+
27
+ class AcquisitionMode(str, Enum):
28
+ SWEPT = "Swept"
29
+ FIXED = "Fixed"
30
+
31
+
32
+ class VGScientaRegion(AbstractBaseRegion):
33
+ # Override defaults of base region class
34
+ lens_mode: str = "Angular45"
35
+ pass_energy: int = 5
36
+ acquisition_mode: str = AcquisitionMode.SWEPT
37
+ low_energy: float = 8.0
38
+ high_energy: float = 10.0
39
+ step_time: float = 1.0
40
+ energy_step: float = Field(default=200.0)
41
+ # Specific to this class
42
+ id: str = Field(default=str(uuid.uuid4()), alias="region_id")
43
+ excitation_energy_source: str = "source1"
44
+ fix_energy: float = 9.0
45
+ total_steps: float = 13.0
46
+ total_time: float = 13.0
47
+ exposure_time: float = 1.0
48
+ first_x_channel: int = 1
49
+ last_x_channel: int = 1000
50
+ first_y_channel: int = 101
51
+ last_y_channel: int = 800
52
+ detector_mode: DetectorMode = DetectorMode.ADC
53
+ status: Status = Status.READY
54
+
55
+ def x_channel_size(self) -> int:
56
+ return self.last_x_channel - self.first_x_channel + 1
57
+
58
+ def y_channel_size(self) -> int:
59
+ return self.last_y_channel - self.first_y_channel + 1
60
+
61
+
62
+ class VGScientaExcitationEnergySource(JavaToPythonModel):
63
+ name: str = "source1"
64
+ device_name: str = Field(default="", alias="scannable_name")
65
+ value: float = 0
66
+
67
+
68
+ class VGScientaSequence(AbstractBaseSequence[VGScientaRegion]):
69
+ element_set: str = Field(default="Unknown")
70
+ excitation_energy_sources: list[VGScientaExcitationEnergySource] = Field(
71
+ default_factory=lambda: []
72
+ )
73
+ regions: list[VGScientaRegion] = Field(default_factory=lambda: [])
74
+
75
+ def get_excitation_energy_source_by_region(
76
+ self, region: VGScientaRegion
77
+ ) -> VGScientaExcitationEnergySource:
78
+ value = next(
79
+ (
80
+ e
81
+ for e in self.excitation_energy_sources
82
+ if region.excitation_energy_source == e.name
83
+ ),
84
+ None,
85
+ )
86
+ if value is None:
87
+ raise ValueError(
88
+ f'Unable to find excitation energy source using region "{region.name}"'
89
+ )
90
+ return value