dls-dodal 1.66.0__py3-none-any.whl → 1.68.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 (83) hide show
  1. {dls_dodal-1.66.0.dist-info → dls_dodal-1.68.0.dist-info}/METADATA +2 -2
  2. {dls_dodal-1.66.0.dist-info → dls_dodal-1.68.0.dist-info}/RECORD +75 -65
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/b07.py +1 -1
  5. dodal/beamlines/b07_1.py +1 -1
  6. dodal/beamlines/i03.py +92 -208
  7. dodal/beamlines/i04.py +22 -1
  8. dodal/beamlines/i05.py +1 -1
  9. dodal/beamlines/i06.py +1 -1
  10. dodal/beamlines/i09.py +1 -1
  11. dodal/beamlines/i09_1.py +27 -3
  12. dodal/beamlines/i09_2.py +58 -2
  13. dodal/beamlines/i10_optics.py +44 -25
  14. dodal/beamlines/i16.py +23 -0
  15. dodal/beamlines/i17.py +7 -3
  16. dodal/beamlines/i19_1.py +26 -14
  17. dodal/beamlines/i19_2.py +49 -38
  18. dodal/beamlines/i21.py +61 -2
  19. dodal/beamlines/i22.py +16 -1
  20. dodal/beamlines/p60.py +1 -1
  21. dodal/beamlines/training_rig.py +0 -16
  22. dodal/cli.py +26 -12
  23. dodal/common/coordination.py +3 -2
  24. dodal/device_manager.py +604 -0
  25. dodal/devices/cryostream.py +28 -57
  26. dodal/devices/eiger.py +41 -27
  27. dodal/devices/electron_analyser/__init__.py +0 -33
  28. dodal/devices/electron_analyser/base/__init__.py +58 -0
  29. dodal/devices/electron_analyser/base/base_controller.py +73 -0
  30. dodal/devices/electron_analyser/base/base_detector.py +214 -0
  31. dodal/devices/electron_analyser/{abstract → base}/base_driver_io.py +23 -42
  32. dodal/devices/electron_analyser/{abstract → base}/base_region.py +47 -11
  33. dodal/devices/electron_analyser/{util.py → base/base_util.py} +1 -1
  34. dodal/devices/electron_analyser/{energy_sources.py → base/energy_sources.py} +1 -1
  35. dodal/devices/electron_analyser/specs/__init__.py +4 -4
  36. dodal/devices/electron_analyser/specs/specs_detector.py +46 -0
  37. dodal/devices/electron_analyser/specs/{driver_io.py → specs_driver_io.py} +23 -26
  38. dodal/devices/electron_analyser/specs/{region.py → specs_region.py} +4 -3
  39. dodal/devices/electron_analyser/vgscienta/__init__.py +4 -4
  40. dodal/devices/electron_analyser/vgscienta/vgscienta_detector.py +52 -0
  41. dodal/devices/electron_analyser/vgscienta/{driver_io.py → vgscienta_driver_io.py} +25 -31
  42. dodal/devices/electron_analyser/vgscienta/{region.py → vgscienta_region.py} +6 -6
  43. dodal/devices/i04/max_pixel.py +38 -0
  44. dodal/devices/i09_1_shared/__init__.py +8 -1
  45. dodal/devices/i09_1_shared/hard_energy.py +112 -0
  46. dodal/devices/i09_2_shared/__init__.py +0 -0
  47. dodal/devices/i09_2_shared/i09_apple2.py +14 -0
  48. dodal/devices/i10/i10_apple2.py +24 -22
  49. dodal/devices/i17/i17_apple2.py +32 -20
  50. dodal/devices/i19/access_controlled/attenuator_motor_squad.py +61 -0
  51. dodal/devices/i19/access_controlled/blueapi_device.py +9 -1
  52. dodal/devices/i19/access_controlled/shutter.py +2 -4
  53. dodal/devices/i21/__init__.py +3 -1
  54. dodal/devices/insertion_device/__init__.py +58 -0
  55. dodal/devices/{apple2_undulator.py → insertion_device/apple2_undulator.py} +102 -44
  56. dodal/devices/insertion_device/energy_motor_lookup.py +88 -0
  57. dodal/devices/insertion_device/id_enum.py +17 -0
  58. dodal/devices/insertion_device/lookup_table_models.py +317 -0
  59. dodal/devices/motors.py +14 -0
  60. dodal/devices/robot.py +16 -11
  61. dodal/plans/__init__.py +1 -1
  62. dodal/plans/configure_arm_trigger_and_disarm_detector.py +2 -4
  63. dodal/testing/electron_analyser/device_factory.py +4 -4
  64. dodal/testing/fixtures/devices/__init__.py +0 -0
  65. dodal/testing/fixtures/devices/apple2.py +78 -0
  66. dodal/testing/fixtures/run_engine.py +4 -0
  67. dodal/utils.py +6 -3
  68. dodal/devices/electron_analyser/abstract/__init__.py +0 -25
  69. dodal/devices/electron_analyser/abstract/base_detector.py +0 -63
  70. dodal/devices/electron_analyser/abstract/types.py +0 -12
  71. dodal/devices/electron_analyser/detector.py +0 -143
  72. dodal/devices/electron_analyser/specs/detector.py +0 -34
  73. dodal/devices/electron_analyser/types.py +0 -57
  74. dodal/devices/electron_analyser/vgscienta/detector.py +0 -48
  75. dodal/devices/util/lookup_tables_apple2.py +0 -390
  76. {dls_dodal-1.66.0.dist-info → dls_dodal-1.68.0.dist-info}/WHEEL +0 -0
  77. {dls_dodal-1.66.0.dist-info → dls_dodal-1.68.0.dist-info}/entry_points.txt +0 -0
  78. {dls_dodal-1.66.0.dist-info → dls_dodal-1.68.0.dist-info}/licenses/LICENSE +0 -0
  79. {dls_dodal-1.66.0.dist-info → dls_dodal-1.68.0.dist-info}/top_level.txt +0 -0
  80. /dodal/devices/electron_analyser/{enums.py → base/base_enums.py} +0 -0
  81. /dodal/devices/electron_analyser/specs/{enums.py → specs_enums.py} +0 -0
  82. /dodal/devices/electron_analyser/vgscienta/{enums.py → vgscienta_enums.py} +0 -0
  83. /dodal/plans/{scanspec.py → spec_path.py} +0 -0
@@ -0,0 +1,52 @@
1
+ from typing import Generic
2
+
3
+ from dodal.devices.electron_analyser.base.base_controller import (
4
+ ElectronAnalyserController,
5
+ )
6
+ from dodal.devices.electron_analyser.base.base_detector import ElectronAnalyserDetector
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
+ )
12
+ from dodal.devices.electron_analyser.vgscienta.vgscienta_driver_io import (
13
+ VGScientaAnalyserDriverIO,
14
+ )
15
+ from dodal.devices.electron_analyser.vgscienta.vgscienta_region import (
16
+ TPassEnergyEnum,
17
+ VGScientaRegion,
18
+ VGScientaSequence,
19
+ )
20
+
21
+
22
+ class VGScientaDetector(
23
+ ElectronAnalyserDetector[
24
+ VGScientaSequence[TLensMode, TPsuMode, TPassEnergyEnum],
25
+ VGScientaAnalyserDriverIO[TLensMode, TPsuMode, TPassEnergyEnum],
26
+ VGScientaRegion[TLensMode, TPassEnergyEnum],
27
+ ],
28
+ Generic[TLensMode, TPsuMode, TPassEnergyEnum],
29
+ ):
30
+ def __init__(
31
+ self,
32
+ prefix: str,
33
+ lens_mode_type: type[TLensMode],
34
+ psu_mode_type: type[TPsuMode],
35
+ pass_energy_type: type[TPassEnergyEnum],
36
+ energy_source: DualEnergySource | EnergySource,
37
+ name: str = "",
38
+ ):
39
+ # Save to class so takes part with connect()
40
+ self.driver = VGScientaAnalyserDriverIO[TLensMode, TPsuMode, TPassEnergyEnum](
41
+ prefix, lens_mode_type, psu_mode_type, pass_energy_type
42
+ )
43
+
44
+ controller = ElectronAnalyserController[
45
+ VGScientaAnalyserDriverIO[TLensMode, TPsuMode, TPassEnergyEnum],
46
+ VGScientaRegion[TLensMode, TPassEnergyEnum],
47
+ ](self.driver, energy_source, 0)
48
+
49
+ sequence_class = VGScientaSequence[
50
+ lens_mode_type, psu_mode_type, pass_energy_type
51
+ ]
52
+ super().__init__(sequence_class, controller, name)
@@ -4,28 +4,22 @@ from typing import Generic
4
4
  import numpy as np
5
5
  from ophyd_async.core import (
6
6
  Array1D,
7
+ AsyncStatus,
7
8
  SignalR,
8
9
  StandardReadableFormat,
9
10
  )
10
11
  from ophyd_async.epics.core import epics_signal_r, epics_signal_rw
11
12
 
12
- from dodal.devices.electron_analyser.abstract.base_driver_io import (
13
+ from dodal.devices.electron_analyser.base.base_driver_io import (
13
14
  AbstractAnalyserDriverIO,
14
15
  )
15
- from dodal.devices.electron_analyser.abstract.types import (
16
- TLensMode,
17
- TPassEnergyEnum,
18
- TPsuMode,
19
- )
20
- from dodal.devices.electron_analyser.energy_sources import (
21
- DualEnergySource,
22
- EnergySource,
23
- )
24
- from dodal.devices.electron_analyser.vgscienta.enums import (
16
+ from dodal.devices.electron_analyser.base.base_region import TLensMode, TPsuMode
17
+ from dodal.devices.electron_analyser.vgscienta.vgscienta_enums import (
25
18
  AcquisitionMode,
26
19
  DetectorMode,
27
20
  )
28
- from dodal.devices.electron_analyser.vgscienta.region import (
21
+ from dodal.devices.electron_analyser.vgscienta.vgscienta_region import (
22
+ TPassEnergyEnum,
29
23
  VGScientaRegion,
30
24
  )
31
25
 
@@ -46,7 +40,6 @@ class VGScientaAnalyserDriverIO(
46
40
  lens_mode_type: type[TLensMode],
47
41
  psu_mode_type: type[TPsuMode],
48
42
  pass_energy_type: type[TPassEnergyEnum],
49
- energy_source: EnergySource | DualEnergySource,
50
43
  name: str = "",
51
44
  ) -> None:
52
45
  with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
@@ -67,28 +60,29 @@ class VGScientaAnalyserDriverIO(
67
60
  lens_mode_type,
68
61
  psu_mode_type,
69
62
  pass_energy_type,
70
- energy_source,
71
63
  name,
72
64
  )
73
65
 
74
- async def _set_region(self, ke_region: VGScientaRegion[TLensMode, TPassEnergyEnum]):
66
+ @AsyncStatus.wrap
67
+ async def set(self, epics_region: VGScientaRegion[TLensMode, TPassEnergyEnum]):
75
68
  await asyncio.gather(
76
- self.region_name.set(ke_region.name),
77
- self.low_energy.set(ke_region.low_energy),
78
- self.centre_energy.set(ke_region.centre_energy),
79
- self.high_energy.set(ke_region.high_energy),
80
- self.slices.set(ke_region.slices),
81
- self.lens_mode.set(ke_region.lens_mode),
82
- self.pass_energy.set(ke_region.pass_energy),
83
- self.iterations.set(ke_region.iterations),
84
- self.acquire_time.set(ke_region.acquire_time),
85
- self.acquisition_mode.set(ke_region.acquisition_mode),
86
- self.energy_step.set(ke_region.energy_step),
87
- self.detector_mode.set(ke_region.detector_mode),
88
- self.region_min_x.set(ke_region.min_x),
89
- self.region_size_x.set(ke_region.size_x),
90
- self.region_min_y.set(ke_region.min_y),
91
- self.region_size_y.set(ke_region.size_y),
69
+ self.region_name.set(epics_region.name),
70
+ self.low_energy.set(epics_region.low_energy),
71
+ self.centre_energy.set(epics_region.centre_energy),
72
+ self.high_energy.set(epics_region.high_energy),
73
+ self.slices.set(epics_region.slices),
74
+ self.lens_mode.set(epics_region.lens_mode),
75
+ self.pass_energy.set(epics_region.pass_energy),
76
+ self.iterations.set(epics_region.iterations),
77
+ self.acquire_time.set(epics_region.acquire_time),
78
+ self.acquisition_mode.set(epics_region.acquisition_mode),
79
+ self.energy_step.set(epics_region.energy_step),
80
+ self.detector_mode.set(epics_region.detector_mode),
81
+ self.region_min_x.set(epics_region.min_x),
82
+ self.region_size_x.set(epics_region.size_x),
83
+ self.region_min_y.set(epics_region.min_y),
84
+ self.region_size_y.set(epics_region.size_y),
85
+ self.energy_mode.set(epics_region.energy_mode),
92
86
  )
93
87
 
94
88
  def _create_energy_axis_signal(self, prefix: str) -> SignalR[Array1D[np.float64]]:
@@ -1,22 +1,22 @@
1
1
  import uuid
2
- from typing import Generic
2
+ from typing import Generic, TypeVar
3
3
 
4
+ from ophyd_async.core import StrictEnum
4
5
  from pydantic import Field, field_validator
5
6
 
6
- from dodal.devices.electron_analyser.abstract.base_region import (
7
+ from dodal.devices.electron_analyser.base.base_region import (
7
8
  AbstractBaseRegion,
8
9
  AbstractBaseSequence,
9
- )
10
- from dodal.devices.electron_analyser.abstract.types import (
11
10
  TLensMode,
12
- TPassEnergyEnum,
13
11
  TPsuMode,
14
12
  )
15
- from dodal.devices.electron_analyser.vgscienta.enums import (
13
+ from dodal.devices.electron_analyser.vgscienta.vgscienta_enums import (
16
14
  AcquisitionMode,
17
15
  DetectorMode,
18
16
  )
19
17
 
18
+ TPassEnergyEnum = TypeVar("TPassEnergyEnum", bound=StrictEnum)
19
+
20
20
 
21
21
  class VGScientaRegion(
22
22
  AbstractBaseRegion[AcquisitionMode, TLensMode, TPassEnergyEnum],
@@ -0,0 +1,38 @@
1
+ import cv2
2
+ import numpy as np
3
+ from bluesky.protocols import Triggerable
4
+ from ophyd_async.core import AsyncStatus, StandardReadable, soft_signal_r_and_setter
5
+ from ophyd_async.epics.core import (
6
+ epics_signal_r,
7
+ )
8
+
9
+ # kernal size describes how many of the neigbouring pixels are used for the blur,
10
+ # higher kernal size means more of a blur effect
11
+ KERNAL_SIZE = (7, 7)
12
+
13
+
14
+ class MaxPixel(StandardReadable, Triggerable):
15
+ """Gets the max pixel (brightest pixel) from the image after some image processing."""
16
+
17
+ def __init__(self, prefix: str, name: str = "") -> None:
18
+ self.array_data = epics_signal_r(np.ndarray, f"pva://{prefix}PVA:ARRAY")
19
+ self.max_pixel_val, self._max_val_setter = soft_signal_r_and_setter(float)
20
+ super().__init__(name)
21
+
22
+ async def _convert_to_gray_and_blur(self):
23
+ """
24
+ Preprocess the image array data (convert to grayscale and apply a gaussian blur)
25
+ Image is converted to grayscale (using a weighted mean as green contributes more to brightness)
26
+ as we aren't interested in data relating to colour. A blur is then applied to mitigate
27
+ errors due to rogue hot pixels.
28
+ """
29
+ data = await self.array_data.get_value()
30
+ gray_arr = cv2.cvtColor(data, cv2.COLOR_BGR2GRAY)
31
+ return cv2.GaussianBlur(gray_arr, KERNAL_SIZE, 0)
32
+
33
+ @AsyncStatus.wrap
34
+ async def trigger(self):
35
+ arr = await self._convert_to_gray_and_blur()
36
+ max_val = float(np.max(arr)) # np.int64
37
+ assert isinstance(max_val, float)
38
+ self._max_val_setter(max_val)
@@ -1,7 +1,14 @@
1
+ from .hard_energy import HardEnergy, HardInsertionDeviceEnergy
1
2
  from .hard_undulator_functions import (
2
3
  calculate_energy_i09_hu,
3
4
  calculate_gap_i09_hu,
4
5
  get_hu_lut_as_dict,
5
6
  )
6
7
 
7
- __all__ = ["calculate_gap_i09_hu", "get_hu_lut_as_dict", "calculate_energy_i09_hu"]
8
+ __all__ = [
9
+ "calculate_gap_i09_hu",
10
+ "get_hu_lut_as_dict",
11
+ "calculate_energy_i09_hu",
12
+ "HardInsertionDeviceEnergy",
13
+ "HardEnergy",
14
+ ]
@@ -0,0 +1,112 @@
1
+ from asyncio import gather
2
+ from collections.abc import Callable
3
+
4
+ from bluesky.protocols import Locatable, Location, Movable
5
+ from numpy import ndarray
6
+ from ophyd_async.core import (
7
+ AsyncStatus,
8
+ Reference,
9
+ StandardReadable,
10
+ StandardReadableFormat,
11
+ derived_signal_rw,
12
+ soft_signal_rw,
13
+ )
14
+
15
+ from dodal.devices.common_dcm import DoubleCrystalMonochromatorBase
16
+ from dodal.devices.i09_1_shared.hard_undulator_functions import (
17
+ MAX_ENERGY_COLUMN,
18
+ MIN_ENERGY_COLUMN,
19
+ )
20
+ from dodal.devices.undulator import UndulatorInMm, UndulatorOrder
21
+
22
+
23
+ class HardInsertionDeviceEnergy(StandardReadable, Movable[float]):
24
+ """
25
+ Compound device to link hard x-ray undulator gap and order to photon energy.
26
+ Setting the energy adjusts the undulator gap accordingly.
27
+ """
28
+
29
+ def __init__(
30
+ self,
31
+ undulator_order: UndulatorOrder,
32
+ undulator: UndulatorInMm,
33
+ lut: dict[int, ndarray],
34
+ gap_to_energy_func: Callable[..., float],
35
+ energy_to_gap_func: Callable[..., float],
36
+ name: str = "",
37
+ ) -> None:
38
+ self._lut = lut
39
+ self.gap_to_energy_func = gap_to_energy_func
40
+ self.energy_to_gap_func = energy_to_gap_func
41
+ self._undulator_order_ref = Reference(undulator_order)
42
+ self._undulator_ref = Reference(undulator)
43
+
44
+ self.add_readables([undulator_order, undulator.current_gap])
45
+ with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
46
+ self.energy_demand = soft_signal_rw(float)
47
+ self.energy = derived_signal_rw(
48
+ raw_to_derived=self._read_energy,
49
+ set_derived=self._set_energy,
50
+ current_gap=self._undulator_ref().gap_motor.user_readback,
51
+ current_order=self._undulator_order_ref().value,
52
+ derived_units="keV",
53
+ )
54
+ super().__init__(name=name)
55
+
56
+ def _read_energy(self, current_gap: float, current_order: int) -> float:
57
+ return self.gap_to_energy_func(
58
+ gap=current_gap,
59
+ look_up_table=self._lut,
60
+ order=current_order,
61
+ )
62
+
63
+ async def _set_energy(self, energy: float) -> None:
64
+ current_order = await self._undulator_order_ref().value.get_value()
65
+ min_energy, max_energy = self._lut[current_order][
66
+ MIN_ENERGY_COLUMN : MAX_ENERGY_COLUMN + 1
67
+ ]
68
+ if not (min_energy <= energy <= max_energy):
69
+ raise ValueError(
70
+ f"Requested energy {energy} keV is out of range for harmonic {current_order}: "
71
+ f"[{min_energy}, {max_energy}] keV"
72
+ )
73
+
74
+ target_gap = self.energy_to_gap_func(
75
+ photon_energy_kev=energy, look_up_table=self._lut, order=current_order
76
+ )
77
+ await self._undulator_ref().set(target_gap)
78
+
79
+ @AsyncStatus.wrap
80
+ async def set(self, value: float) -> None:
81
+ self.energy_demand.set(value)
82
+ await self.energy.set(value)
83
+
84
+
85
+ class HardEnergy(StandardReadable, Locatable[float]):
86
+ """
87
+ Energy compound device that provides combined change of both DCM energy and undulator gap accordingly.
88
+ """
89
+
90
+ def __init__(
91
+ self,
92
+ dcm: DoubleCrystalMonochromatorBase,
93
+ undulator_energy: HardInsertionDeviceEnergy,
94
+ name: str = "",
95
+ ) -> None:
96
+ self._dcm_ref = Reference(dcm)
97
+ self._undulator_energy_ref = Reference(undulator_energy)
98
+ self.add_readables([undulator_energy, dcm.energy_in_keV])
99
+ super().__init__(name=name)
100
+
101
+ @AsyncStatus.wrap
102
+ async def set(self, value: float) -> None:
103
+ await gather(
104
+ self._dcm_ref().energy_in_keV.set(value),
105
+ self._undulator_energy_ref().set(value),
106
+ )
107
+
108
+ async def locate(self) -> Location[float]:
109
+ return Location(
110
+ setpoint=await self._dcm_ref().energy_in_keV.user_setpoint.get_value(),
111
+ readback=await self._dcm_ref().energy_in_keV.user_readback.get_value(),
112
+ )
File without changes
@@ -0,0 +1,14 @@
1
+ J09_GAP_POLY_DEG_COLUMNS = [
2
+ "9th-order",
3
+ "8th-order",
4
+ "7th-order",
5
+ "6th-order",
6
+ "5th-order",
7
+ "4th-order",
8
+ "3rd-order",
9
+ "2nd-order",
10
+ "1st-order",
11
+ "0th-order",
12
+ ]
13
+
14
+ J09_PHASE_POLY_DEG_COLUMNS = ["0th-order"]
@@ -11,19 +11,18 @@ from ophyd_async.core import (
11
11
  soft_signal_rw,
12
12
  )
13
13
 
14
- from dodal.devices.apple2_undulator import (
14
+ from dodal.devices.insertion_device import (
15
15
  MAXIMUM_MOVE_TIME,
16
16
  Apple2,
17
17
  Apple2Controller,
18
18
  Apple2PhasesVal,
19
19
  Apple2Val,
20
- Pol,
21
20
  UndulatorGap,
22
21
  UndulatorJawPhase,
23
22
  UndulatorPhaseAxes,
24
23
  )
25
- from dodal.devices.util.lookup_tables_apple2 import EnergyMotorLookup
26
- from dodal.log import LOGGER
24
+ from dodal.devices.insertion_device.energy_motor_lookup import EnergyMotorLookup
25
+ from dodal.devices.insertion_device.id_enum import Pol
27
26
 
28
27
  ROW_PHASE_MOTOR_TOLERANCE = 0.004
29
28
  MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
@@ -62,16 +61,18 @@ class I10Apple2(Apple2[UndulatorPhaseAxes]):
62
61
  class I10Apple2Controller(Apple2Controller[I10Apple2]):
63
62
  """
64
63
  I10Apple2Controller is a extension of Apple2Controller which provide linear
65
- arbitrary angle control.
64
+ arbitrary angle control.
66
65
  """
67
66
 
68
67
  def __init__(
69
68
  self,
70
69
  apple2: I10Apple2,
71
- energy_motor_lut: EnergyMotorLookup,
70
+ gap_energy_motor_lut: EnergyMotorLookup,
71
+ phase_energy_motor_lut: EnergyMotorLookup,
72
72
  jaw_phase_limit: float = 12.0,
73
73
  jaw_phase_poly_param: list[float] = DEFAULT_JAW_PHASE_POLY_PARAMS,
74
74
  angle_threshold_deg=30.0,
75
+ units: str = "eV",
75
76
  name: str = "",
76
77
  ) -> None:
77
78
  """
@@ -79,25 +80,30 @@ class I10Apple2Controller(Apple2Controller[I10Apple2]):
79
80
  -----------
80
81
  apple2 : I10Apple2
81
82
  An I10Apple2 device.
82
- energy_motor_lut: EnergyMotorLookup
83
- The class that handles the look up table logic for the insertion device.
83
+ gap_energy_motor_lut: EnergyMotorLookup
84
+ The class that handles the gap look up table logic for the insertion device.
85
+ phase_energy_motor_lut: EnergyMotorLookup
86
+ The class that handles the phase look up table logic for the insertion device.
84
87
  jaw_phase_limit : float, optional
85
88
  The maximum allowed jaw_phase movement., by default 12.0
86
89
  jaw_phase_poly_param : list[float], optional
87
90
  polynomial parameters highest power first., by default DEFAULT_JAW_PHASE_POLY_PARAMS
88
91
  angle_threshold_deg : float, optional
89
92
  The angle threshold to switch between 0-180 and 180-360 range., by default 30.0
93
+ units:
94
+ the units of this device. Defaults to eV.
90
95
  name : str, optional
91
96
  New device name.
92
97
  """
93
-
94
- self.energy_motor_lut = energy_motor_lut
98
+ self.gap_energy_motor_lut = gap_energy_motor_lut
99
+ self.phase_energy_motor_lut = phase_energy_motor_lut
95
100
  super().__init__(
96
101
  apple2=apple2,
97
- energy_to_motor_converter=self.energy_motor_lut.get_motor_from_energy,
102
+ gap_energy_motor_converter=gap_energy_motor_lut.find_value_in_lookup_table,
103
+ phase_energy_motor_converter=phase_energy_motor_lut.find_value_in_lookup_table,
104
+ units=units,
98
105
  name=name,
99
106
  )
100
-
101
107
  self.jaw_phase_from_angle = np.poly1d(jaw_phase_poly_param)
102
108
  self.angle_threshold_deg = angle_threshold_deg
103
109
  self.jaw_phase_limit = jaw_phase_limit
@@ -132,15 +138,9 @@ class I10Apple2Controller(Apple2Controller[I10Apple2]):
132
138
  await self.apple2().jaw_phase().set(jaw_phase)
133
139
  await self._linear_arbitrary_angle.set(pol_angle)
134
140
 
135
- async def _set_motors_from_energy(self, value: float) -> None:
136
- """
137
- Set the undulator motors for a given energy and polarisation.
138
- """
139
-
140
- pol = await self._check_and_get_pol_setpoint()
141
- gap, phase = self.energy_to_motor(energy=value, pol=pol)
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
- id_set_val = Apple2Val(
143
+ return Apple2Val(
144
144
  gap=f"{gap:.6f}",
145
145
  phase=Apple2PhasesVal(
146
146
  top_outer=f"{phase:.6f}",
@@ -150,8 +150,10 @@ class I10Apple2Controller(Apple2Controller[I10Apple2]):
150
150
  ),
151
151
  )
152
152
 
153
- LOGGER.info(f"Setting polarisation to {pol}, with values: {id_set_val}")
154
- await self.apple2().set(id_motor_values=id_set_val)
153
+ async def _set_motors_from_energy_and_polarisation(
154
+ self, energy: float, pol: Pol
155
+ ) -> None:
156
+ await super()._set_motors_from_energy_and_polarisation(energy, pol)
155
157
  if pol != Pol.LA:
156
158
  await self.apple2().jaw_phase().set(0)
157
159
  await self.apple2().jaw_phase().set_move.set(1)
@@ -1,11 +1,12 @@
1
- from dodal.devices.apple2_undulator import (
1
+ from dodal.devices.insertion_device.apple2_undulator import (
2
2
  Apple2,
3
3
  Apple2Controller,
4
4
  Apple2PhasesVal,
5
5
  Apple2Val,
6
- EnergyMotorConvertor,
6
+ Pol,
7
+ UndulatorPhaseAxes,
7
8
  )
8
- from dodal.log import LOGGER
9
+ from dodal.devices.insertion_device.energy_motor_lookup import EnergyMotorLookup
9
10
 
10
11
  ROW_PHASE_MOTOR_TOLERANCE = 0.004
11
12
  MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
@@ -15,7 +16,7 @@ ALPHA_OFFSET = 180
15
16
  MAXIMUM_MOVE_TIME = 550 # There is no useful movements take longer than this.
16
17
 
17
18
 
18
- class I17Apple2Controller(Apple2Controller[Apple2]):
19
+ class I17Apple2Controller(Apple2Controller[Apple2[UndulatorPhaseAxes]]):
19
20
  """
20
21
  I10Apple2Controller is a extension of Apple2Controller which provide linear
21
22
  arbitrary angle control.
@@ -23,32 +24,43 @@ class I17Apple2Controller(Apple2Controller[Apple2]):
23
24
 
24
25
  def __init__(
25
26
  self,
26
- apple2: Apple2,
27
- energy_to_motor_converter: EnergyMotorConvertor,
27
+ apple2: Apple2[UndulatorPhaseAxes],
28
+ gap_energy_motor_lut: EnergyMotorLookup,
29
+ phase_energy_motor_lut: EnergyMotorLookup,
30
+ units: str = "eV",
28
31
  name: str = "",
29
32
  ) -> None:
33
+ """
34
+ Parameters:
35
+ -----------
36
+ apple2 : Apple2
37
+ An Apple2 device.
38
+ gap_energy_motor_lut: EnergyMotorLookup
39
+ The class that handles the gap look up table logic for the insertion device.
40
+ phase_energy_motor_lut: EnergyMotorLookup
41
+ The class that handles the phase look up table logic for the insertion device.
42
+ units:
43
+ the units of this device. Defaults to eV.
44
+ name : str, optional
45
+ New device name.
46
+ """
47
+ self.gap_energy_motor_lut = gap_energy_motor_lut
48
+ self.phase_energy_motor_lut = phase_energy_motor_lut
30
49
  super().__init__(
31
50
  apple2=apple2,
32
- energy_to_motor_converter=energy_to_motor_converter,
51
+ gap_energy_motor_converter=gap_energy_motor_lut.find_value_in_lookup_table,
52
+ phase_energy_motor_converter=phase_energy_motor_lut.find_value_in_lookup_table,
53
+ units=units,
33
54
  name=name,
34
55
  )
35
56
 
36
- async def _set_motors_from_energy(self, value: float) -> None:
37
- """
38
- Set the undulator motors for a given energy and polarisation.
39
- """
40
-
41
- pol = await self._check_and_get_pol_setpoint()
42
- gap, phase = self.energy_to_motor(energy=value, pol=pol)
43
- id_set_val = Apple2Val(
57
+ def _get_apple2_value(self, gap: float, phase: float, pol: Pol) -> Apple2Val:
58
+ return Apple2Val(
44
59
  gap=f"{gap:.6f}",
45
60
  phase=Apple2PhasesVal(
46
61
  top_outer=f"{phase:.6f}",
47
- top_inner="0.0",
62
+ top_inner=f"{0.0:.6f}",
48
63
  btm_inner=f"{phase:.6f}",
49
- btm_outer="0.0",
64
+ btm_outer=f"{0.0:.6f}",
50
65
  ),
51
66
  )
52
-
53
- LOGGER.info(f"Setting polarisation to {pol}, with values: {id_set_val}")
54
- await self.apple2().set(id_motor_values=id_set_val)
@@ -0,0 +1,61 @@
1
+ from typing import Annotated, Any, Self
2
+
3
+ from ophyd_async.core import AsyncStatus
4
+ from pydantic import BaseModel, model_validator
5
+ from pydantic.types import PositiveInt, StringConstraints
6
+
7
+ from dodal.devices.i19.access_controlled.blueapi_device import (
8
+ OpticsBlueAPIDevice,
9
+ )
10
+ from dodal.devices.i19.access_controlled.hutch_access import ACCESS_DEVICE_NAME
11
+
12
+ PermittedKeyStr = Annotated[str, StringConstraints(pattern="^[A-Za-z0-9-_]*$")]
13
+
14
+
15
+ class AttenuatorMotorPositionDemands(BaseModel):
16
+ continuous_demands: dict[PermittedKeyStr, float] = {}
17
+ indexed_demands: dict[PermittedKeyStr, PositiveInt] = {}
18
+
19
+ @model_validator(mode="after")
20
+ def no_keys_clash(self) -> Self:
21
+ common_keys = set(self.continuous_demands).intersection(self.indexed_demands)
22
+ common_key_count = sum(1 for _ in common_keys)
23
+ if common_key_count < 1:
24
+ return self
25
+ else:
26
+ ks: str = "key" if common_key_count == 1 else "keys"
27
+ error_msg = f"Common {ks} found in distinct motor demands: {common_keys}"
28
+ raise ValueError(error_msg)
29
+
30
+ def validated_complete_demand(self) -> dict[PermittedKeyStr, Any]:
31
+ return self.continuous_demands | self.indexed_demands
32
+
33
+
34
+ class AttenuatorMotorSquad(OpticsBlueAPIDevice):
35
+ """ I19-specific proxy device which requests absorber position changes in the x-ray attenuator.
36
+
37
+ Sends REST call to blueapi controlling optics on the I19 cluster.
38
+ The hutch in use is compared against the hutch which sent the REST call.
39
+ Only the hutch in use will be permitted to execute a plan (requesting motor moves).
40
+ As the two hutches are located in series, checking the hutch in use is necessary to \
41
+ avoid accidentally operating optics devices from one hutch while the other has beam time.
42
+
43
+ The name of the hutch that wants to operate the optics device is passed to the \
44
+ access controlled device upon instantiation of the latter.
45
+
46
+ For details see the architecture described in \
47
+ https://github.com/DiamondLightSource/i19-bluesky/issues/30.
48
+ """
49
+
50
+ @AsyncStatus.wrap
51
+ async def set(self, value: AttenuatorMotorPositionDemands):
52
+ request_params = {
53
+ "name": "operate_motor_squad_plan",
54
+ "params": {
55
+ "experiment_hutch": self._invoking_hutch,
56
+ "access_device": ACCESS_DEVICE_NAME,
57
+ "attenuator_demands": value.validated_complete_demand(),
58
+ },
59
+ "instrument_session": self.instrument_session,
60
+ }
61
+ await super().set(request_params)
@@ -29,11 +29,19 @@ class OpticsBlueAPIDevice(StandardReadable, Movable[D]):
29
29
  https://github.com/DiamondLightSource/i19-bluesky/issues/30.
30
30
  """
31
31
 
32
- def __init__(self, name: str = "") -> None:
32
+ def __init__(
33
+ self, hutch: HutchState, instrument_session: str = "", name: str = ""
34
+ ) -> None:
35
+ self.hutch_request = hutch
36
+ self.instrument_session = instrument_session
33
37
  self.url = OPTICS_BLUEAPI_URL
34
38
  self.headers = HEADERS
35
39
  super().__init__(name)
36
40
 
41
+ @property
42
+ def _invoking_hutch(self) -> str:
43
+ return self.hutch_request.value
44
+
37
45
  @AsyncStatus.wrap
38
46
  async def set(self, value: D):
39
47
  """ On set send a POST request to the optics blueapi with the name and \
@@ -36,16 +36,14 @@ class AccessControlledShutter(OpticsBlueAPIDevice):
36
36
  # see https://github.com/DiamondLightSource/blueapi/issues/1187
37
37
  with self.add_children_as_readables(StandardReadableFormat.HINTED_SIGNAL):
38
38
  self.shutter_status = epics_signal_r(ShutterState, f"{prefix}STA")
39
- self.hutch_request = hutch
40
- self.instrument_session = instrument_session
41
- super().__init__(name)
39
+ super().__init__(hutch=hutch, instrument_session=instrument_session, name=name)
42
40
 
43
41
  @AsyncStatus.wrap
44
42
  async def set(self, value: ShutterDemand):
45
43
  request_params = {
46
44
  "name": "operate_shutter_plan",
47
45
  "params": {
48
- "experiment_hutch": self.hutch_request.value,
46
+ "experiment_hutch": self._invoking_hutch,
49
47
  "access_device": ACCESS_DEVICE_NAME,
50
48
  "shutter_demand": value.value,
51
49
  },
@@ -1,3 +1,5 @@
1
1
  from dodal.devices.i21.enums import Grating
2
2
 
3
- __all__ = ["Grating"]
3
+ __all__ = [
4
+ "Grating",
5
+ ]