dls-dodal 1.67.0__py3-none-any.whl → 1.69.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/METADATA +2 -32
  2. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/RECORD +79 -71
  3. dodal/_version.py +2 -2
  4. dodal/beamlines/adsim.py +30 -23
  5. dodal/beamlines/b07.py +1 -1
  6. dodal/beamlines/b07_1.py +1 -1
  7. dodal/beamlines/i02_1.py +14 -42
  8. dodal/beamlines/i02_2.py +5 -11
  9. dodal/beamlines/i03.py +4 -1
  10. dodal/beamlines/i03_supervisor.py +19 -0
  11. dodal/beamlines/i04.py +74 -179
  12. dodal/beamlines/i05.py +9 -1
  13. dodal/beamlines/i06.py +1 -1
  14. dodal/beamlines/i06_1.py +24 -0
  15. dodal/beamlines/i09.py +53 -9
  16. dodal/beamlines/i09_1.py +9 -1
  17. dodal/beamlines/i09_2.py +7 -6
  18. dodal/beamlines/i10_optics.py +1 -1
  19. dodal/beamlines/i16.py +34 -0
  20. dodal/beamlines/i17.py +1 -1
  21. dodal/beamlines/i20_1.py +14 -0
  22. dodal/beamlines/i21.py +71 -4
  23. dodal/beamlines/i23.py +19 -25
  24. dodal/beamlines/i24.py +55 -105
  25. dodal/beamlines/p60.py +12 -2
  26. dodal/common/__init__.py +2 -1
  27. dodal/common/maths.py +80 -0
  28. dodal/devices/eiger.py +44 -23
  29. dodal/devices/electron_analyser/__init__.py +0 -33
  30. dodal/devices/electron_analyser/base/__init__.py +58 -0
  31. dodal/devices/electron_analyser/base/base_controller.py +84 -0
  32. dodal/devices/electron_analyser/base/base_detector.py +214 -0
  33. dodal/devices/electron_analyser/{abstract → base}/base_driver_io.py +23 -42
  34. dodal/devices/electron_analyser/{enums.py → base/base_enums.py} +0 -5
  35. dodal/devices/electron_analyser/{abstract → base}/base_region.py +48 -11
  36. dodal/devices/electron_analyser/{util.py → base/base_util.py} +1 -1
  37. dodal/devices/electron_analyser/{energy_sources.py → base/energy_sources.py} +27 -26
  38. dodal/devices/electron_analyser/specs/__init__.py +4 -4
  39. dodal/devices/electron_analyser/specs/specs_detector.py +47 -0
  40. dodal/devices/electron_analyser/specs/{driver_io.py → specs_driver_io.py} +23 -26
  41. dodal/devices/electron_analyser/specs/{region.py → specs_region.py} +4 -3
  42. dodal/devices/electron_analyser/vgscienta/__init__.py +4 -4
  43. dodal/devices/electron_analyser/vgscienta/vgscienta_detector.py +53 -0
  44. dodal/devices/electron_analyser/vgscienta/{driver_io.py → vgscienta_driver_io.py} +25 -31
  45. dodal/devices/electron_analyser/vgscienta/{region.py → vgscienta_region.py} +6 -6
  46. dodal/devices/fast_shutter.py +108 -25
  47. dodal/devices/i04/beam_centre.py +84 -0
  48. dodal/devices/i04/max_pixel.py +4 -17
  49. dodal/devices/i04/murko_results.py +18 -3
  50. dodal/devices/i09_2_shared/i09_apple2.py +0 -72
  51. dodal/devices/i10/i10_apple2.py +7 -7
  52. dodal/devices/i17/i17_apple2.py +6 -6
  53. dodal/devices/i21/__init__.py +3 -1
  54. dodal/devices/i24/commissioning_jungfrau.py +9 -10
  55. dodal/devices/insertion_device/__init__.py +62 -0
  56. dodal/devices/insertion_device/apple2_controller.py +380 -0
  57. dodal/devices/insertion_device/apple2_undulator.py +152 -481
  58. dodal/devices/insertion_device/energy.py +88 -0
  59. dodal/devices/insertion_device/energy_motor_lookup.py +1 -1
  60. dodal/devices/insertion_device/enum.py +17 -0
  61. dodal/devices/insertion_device/lookup_table_models.py +66 -36
  62. dodal/devices/insertion_device/polarisation.py +36 -0
  63. dodal/devices/oav/oav_detector.py +66 -1
  64. dodal/devices/oav/utils.py +17 -0
  65. dodal/devices/robot.py +35 -18
  66. dodal/devices/selectable_source.py +38 -0
  67. dodal/devices/zebra/zebra.py +15 -0
  68. dodal/devices/zebra/zebra_constants_mapping.py +1 -0
  69. dodal/plans/configure_arm_trigger_and_disarm_detector.py +0 -1
  70. dodal/testing/__init__.py +0 -0
  71. dodal/testing/electron_analyser/device_factory.py +4 -4
  72. dodal/testing/fixtures/devices/apple2.py +1 -1
  73. dodal/testing/fixtures/run_engine.py +4 -0
  74. dodal/devices/electron_analyser/abstract/__init__.py +0 -25
  75. dodal/devices/electron_analyser/abstract/base_detector.py +0 -63
  76. dodal/devices/electron_analyser/abstract/types.py +0 -12
  77. dodal/devices/electron_analyser/detector.py +0 -143
  78. dodal/devices/electron_analyser/specs/detector.py +0 -34
  79. dodal/devices/electron_analyser/types.py +0 -57
  80. dodal/devices/electron_analyser/vgscienta/detector.py +0 -48
  81. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/WHEEL +0 -0
  82. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/entry_points.txt +0 -0
  83. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/licenses/LICENSE +0 -0
  84. {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/top_level.txt +0 -0
  85. /dodal/devices/electron_analyser/specs/{enums.py → specs_enums.py} +0 -0
  86. /dodal/devices/electron_analyser/vgscienta/{enums.py → vgscienta_enums.py} +0 -0
@@ -0,0 +1,88 @@
1
+ import abc
2
+ import asyncio
3
+
4
+ from bluesky.protocols import Movable
5
+ from ophyd_async.core import (
6
+ AsyncStatus,
7
+ Reference,
8
+ SignalRW,
9
+ StandardReadable,
10
+ StandardReadableFormat,
11
+ soft_signal_rw,
12
+ )
13
+ from ophyd_async.epics.motor import Motor
14
+
15
+ from dodal.devices.insertion_device import MAXIMUM_MOVE_TIME, Apple2Controller
16
+ from dodal.log import LOGGER
17
+
18
+
19
+ class InsertionDeviceEnergyBase(abc.ABC, StandardReadable, Movable):
20
+ """Base class for ID energy movable device."""
21
+
22
+ def __init__(self, name: str = "") -> None:
23
+ self.energy: Reference[SignalRW[float]]
24
+ super().__init__(name=name)
25
+
26
+ @abc.abstractmethod
27
+ @AsyncStatus.wrap
28
+ async def set(self, energy: float) -> None: ...
29
+
30
+
31
+ class BeamEnergy(StandardReadable, Movable[float]):
32
+ """
33
+ Compound device to set both ID and energy motor at the same time with an option to add an offset.
34
+ """
35
+
36
+ def __init__(
37
+ self, id_energy: InsertionDeviceEnergyBase, mono: Motor, name: str = ""
38
+ ) -> None:
39
+ """
40
+ Parameters
41
+ ----------
42
+
43
+ id_energy: InsertionDeviceEnergy
44
+ An InsertionDeviceEnergy device.
45
+ mono: Motor
46
+ A Motor(energy) device.
47
+ name:
48
+ New device name.
49
+ """
50
+ super().__init__(name=name)
51
+ self._id_energy = Reference(id_energy)
52
+ self._mono_energy = Reference(mono)
53
+
54
+ self.add_readables(
55
+ [
56
+ self._id_energy().energy(),
57
+ self._mono_energy().user_readback,
58
+ ],
59
+ StandardReadableFormat.HINTED_SIGNAL,
60
+ )
61
+
62
+ with self.add_children_as_readables(StandardReadableFormat.CONFIG_SIGNAL):
63
+ self.id_energy_offset = soft_signal_rw(float, initial_value=0)
64
+
65
+ @AsyncStatus.wrap
66
+ async def set(self, energy: float) -> None:
67
+ LOGGER.info(f"Moving f{self.name} energy to {energy}.")
68
+ await asyncio.gather(
69
+ self._id_energy().set(
70
+ energy=energy + await self.id_energy_offset.get_value()
71
+ ),
72
+ self._mono_energy().set(energy),
73
+ )
74
+
75
+
76
+ class InsertionDeviceEnergy(InsertionDeviceEnergyBase):
77
+ """Apple2 ID energy movable device."""
78
+
79
+ def __init__(self, id_controller: Apple2Controller, name: str = "") -> None:
80
+ self.energy = Reference(id_controller.energy)
81
+ super().__init__(name=name)
82
+
83
+ self.add_readables([self.energy()], StandardReadableFormat.HINTED_SIGNAL)
84
+
85
+ @AsyncStatus.wrap
86
+ async def set(self, energy: float) -> None:
87
+ LOGGER.info(f"Setting insertion device energy to {energy}.")
88
+ await self.energy().set(energy, timeout=MAXIMUM_MOVE_TIME)
@@ -2,7 +2,7 @@ from pathlib import Path
2
2
 
3
3
  from daq_config_server.client import ConfigServer
4
4
 
5
- from dodal.devices.insertion_device.apple2_undulator import Pol
5
+ from dodal.devices.insertion_device.enum import Pol
6
6
  from dodal.devices.insertion_device.lookup_table_models import (
7
7
  LookupTable,
8
8
  LookupTableColumnConfig,
@@ -0,0 +1,17 @@
1
+ from ophyd_async.core import StrictEnum
2
+
3
+
4
+ class Pol(StrictEnum):
5
+ NONE = "None"
6
+ LH = "lh"
7
+ LV = "lv"
8
+ PC = "pc"
9
+ NC = "nc"
10
+ LA = "la"
11
+ LH3 = "lh3"
12
+ LV3 = "lv3"
13
+
14
+
15
+ class UndulatorGateStatus(StrictEnum):
16
+ OPEN = "Open"
17
+ CLOSE = "Closed"
@@ -39,7 +39,7 @@ from pydantic import (
39
39
  field_validator,
40
40
  )
41
41
 
42
- from dodal.devices.insertion_device.apple2_undulator import Pol
42
+ from dodal.devices.insertion_device.enum import Pol
43
43
 
44
44
  DEFAULT_POLY_DEG = [
45
45
  "7th-order",
@@ -57,7 +57,7 @@ DEFAULT_GAP_FILE = "IDEnergy2GapCalibrations.csv"
57
57
  DEFAULT_PHASE_FILE = "IDEnergy2PhaseCalibrations.csv"
58
58
 
59
59
  ROW_PHASE_MOTOR_TOLERANCE = 0.004
60
- ROW_PHASE_CIRCULAR = 15
60
+ ROW_PHASE_CIRCULAR = 15.0
61
61
  MAXIMUM_ROW_PHASE_MOTOR_POSITION = 24.0
62
62
  MAXIMUM_GAP_MOTOR_POSITION = 100
63
63
 
@@ -94,13 +94,19 @@ class LookupTableColumnConfig(BaseModel):
94
94
  description="When processing polarisation mode values, map their alias values to a real value.",
95
95
  default_factory=lambda: MODE_NAME_CONVERT,
96
96
  )
97
+ grating: A[
98
+ str | None, Field(description="Optional column name for entry grating.")
99
+ ] = None
97
100
 
98
101
 
99
102
  class EnergyCoverageEntry(BaseModel):
100
- model_config = ConfigDict(arbitrary_types_allowed=True) # So np.poly1d can be used.
103
+ model_config = ConfigDict(
104
+ arbitrary_types_allowed=True, frozen=True
105
+ ) # arbitrary_types_allowed is True so np.poly1d can be used.
101
106
  min_energy: float
102
107
  max_energy: float
103
108
  poly: np.poly1d
109
+ grating: float | None = None
104
110
 
105
111
  @field_validator("poly", mode="before")
106
112
  @classmethod
@@ -119,7 +125,16 @@ class EnergyCoverageEntry(BaseModel):
119
125
 
120
126
 
121
127
  class EnergyCoverage(BaseModel):
122
- energy_entries: list[EnergyCoverageEntry]
128
+ model_config = ConfigDict(frozen=True)
129
+ energy_entries: tuple[EnergyCoverageEntry, ...]
130
+
131
+ @field_validator("energy_entries", mode="after")
132
+ @classmethod
133
+ def _prepare_energy_entries(
134
+ cls, value: tuple[EnergyCoverageEntry, ...]
135
+ ) -> tuple[EnergyCoverageEntry, ...]:
136
+ """Convert incoming energy_entries to a sorted, immutable tuple."""
137
+ return tuple(sorted(value, key=lambda e: e.min_energy))
123
138
 
124
139
  @classmethod
125
140
  def generate(
@@ -128,7 +143,7 @@ class EnergyCoverage(BaseModel):
128
143
  max_energies: list[float],
129
144
  poly1d_params: list[list[float]],
130
145
  ) -> Self:
131
- energy_entries = [
146
+ energy_entries = tuple(
132
147
  EnergyCoverageEntry(
133
148
  min_energy=min_energy,
134
149
  max_energy=max_energy,
@@ -137,16 +152,16 @@ class EnergyCoverage(BaseModel):
137
152
  for min_energy, max_energy, poly_params in zip(
138
153
  min_energies, max_energies, poly1d_params, strict=True
139
154
  )
140
- ]
155
+ )
141
156
  return cls(energy_entries=energy_entries)
142
157
 
143
158
  @property
144
159
  def min_energy(self) -> float:
145
- return min(e.min_energy for e in self.energy_entries)
160
+ return self.energy_entries[0].min_energy
146
161
 
147
162
  @property
148
163
  def max_energy(self) -> float:
149
- return max(e.max_energy for e in self.energy_entries)
164
+ return self.energy_entries[-1].max_energy
150
165
 
151
166
  def get_poly(self, energy: float) -> np.poly1d:
152
167
  """
@@ -157,25 +172,36 @@ class EnergyCoverage(BaseModel):
157
172
  energy:
158
173
  Energy value in the same units used to create the lookup table.
159
174
  """
160
- # Cache initial values so don't do unnecessary work again
161
- min_energy = self.min_energy
162
- max_energy = self.max_energy
163
- if energy < min_energy or energy > max_energy:
175
+
176
+ if not self.min_energy <= energy <= self.max_energy:
164
177
  raise ValueError(
165
- f"Demanding energy must lie between {min_energy} and {max_energy}!"
178
+ f"Demanding energy must lie between {self.min_energy} and {self.max_energy}!"
166
179
  )
167
- else:
168
- for energy_coverage in self.energy_entries:
169
- if (
170
- energy >= energy_coverage.min_energy
171
- and energy < energy_coverage.max_energy
172
- ):
173
- return energy_coverage.poly
180
+
181
+ poly_index = self.get_energy_index(energy)
182
+ if poly_index is not None:
183
+ return self.energy_entries[poly_index].poly
174
184
  raise ValueError(
175
185
  "Cannot find polynomial coefficients for your requested energy."
176
186
  + " There might be gap in the calibration lookup table."
177
187
  )
178
188
 
189
+ def get_energy_index(self, energy: float) -> int | None:
190
+ """Binary search assumes self.energy_entries is sorted by min_energy.
191
+ Return index or None if not found."""
192
+ max_index = len(self.energy_entries) - 1
193
+ min_index = 0
194
+ while min_index <= max_index:
195
+ mid_index = (min_index + max_index) // 2
196
+ en_try = self.energy_entries[mid_index]
197
+ if en_try.min_energy <= energy <= en_try.max_energy:
198
+ return mid_index
199
+ elif energy < en_try.min_energy:
200
+ max_index = mid_index - 1
201
+ else:
202
+ min_index = mid_index + 1
203
+ return None
204
+
179
205
 
180
206
  class LookupTable(RootModel[dict[Pol, EnergyCoverage]]):
181
207
  """
@@ -183,6 +209,8 @@ class LookupTable(RootModel[dict[Pol, EnergyCoverage]]):
183
209
  values to Apple2 motor positions.
184
210
  """
185
211
 
212
+ model_config = ConfigDict(frozen=True)
213
+
186
214
  # Allow to auto specify a dict if one not provided
187
215
  def __init__(self, root: dict[Pol, EnergyCoverage] | None = None):
188
216
  super().__init__(root=root or {})
@@ -195,10 +223,8 @@ class LookupTable(RootModel[dict[Pol, EnergyCoverage]]):
195
223
  ) -> Self:
196
224
  """Generate a LookupTable containing multiple EnergyCoverage
197
225
  for provided polarisations."""
198
- lut = cls()
199
- for i in range(len(pols)):
200
- lut.root[pols[i]] = energy_coverage[i]
201
- return lut
226
+ root_data = dict(zip(pols, energy_coverage, strict=False))
227
+ return cls(root=root_data)
202
228
 
203
229
  def get_poly(
204
230
  self,
@@ -239,15 +265,15 @@ def convert_csv_to_lookup(
239
265
  -----------
240
266
  LookupTable
241
267
  """
268
+ temp_mode_entries: dict[Pol, list[EnergyCoverageEntry]] = {}
242
269
 
243
- def process_row(row: dict[str, Any], lut: LookupTable) -> None:
244
- """Process a single row from the CSV file and update the lookup table."""
270
+ def process_row(row: dict[str, Any]) -> None:
271
+ """Process a single row from the CSV file and update the temporary entry list."""
245
272
  raw_mode_value = str(row[lut_config.mode]).lower()
246
273
  mode_value = Pol(
247
274
  lut_config.mode_name_convert.get(raw_mode_value, raw_mode_value)
248
275
  )
249
276
 
250
- # Create polynomial object for energy-to-gap/phase conversion
251
277
  coefficients = np.poly1d([float(row[coef]) for coef in lut_config.poly_deg])
252
278
 
253
279
  energy_entry = EnergyCoverageEntry(
@@ -255,27 +281,31 @@ def convert_csv_to_lookup(
255
281
  max_energy=float(row[lut_config.max_energy]),
256
282
  poly=coefficients,
257
283
  )
258
- if mode_value not in lut.root:
259
- lut.root[mode_value] = EnergyCoverage(energy_entries=[energy_entry])
260
- else:
261
- lut.root[mode_value].energy_entries.append(energy_entry)
284
+
285
+ if mode_value not in temp_mode_entries:
286
+ temp_mode_entries[mode_value] = []
287
+
288
+ temp_mode_entries[mode_value].append(energy_entry)
262
289
 
263
290
  reader = csv.DictReader(read_file_and_skip(file_contents, skip_line_start_with))
264
- lut = LookupTable()
265
291
 
266
292
  for row in reader:
267
293
  source = lut_config.source
268
294
  # If there are multiple source only convert requested.
269
295
  if source is None or row[source.column] == source.value:
270
- process_row(row=row, lut=lut)
271
-
296
+ process_row(row=row)
272
297
  # Check if our LookupTable is empty after processing, raise error if it is.
273
- if not lut.root:
298
+ if not temp_mode_entries:
274
299
  raise RuntimeError(
275
300
  "LookupTable content is empty, failed to convert the file contents to "
276
301
  "a LookupTable!"
277
302
  )
278
- return lut
303
+
304
+ final_lut_root: dict[Pol, EnergyCoverage] = {}
305
+ for pol, entries in temp_mode_entries.items():
306
+ final_lut_root[pol] = EnergyCoverage.model_validate({"energy_entries": entries})
307
+
308
+ return LookupTable(root=final_lut_root)
279
309
 
280
310
 
281
311
  def read_file_and_skip(file: str, skip_line_start_with: str = "#") -> Generator[str]:
@@ -0,0 +1,36 @@
1
+ import asyncio
2
+
3
+ from bluesky.protocols import Locatable, Location
4
+ from ophyd_async.core import (
5
+ AsyncStatus,
6
+ Reference,
7
+ StandardReadable,
8
+ StandardReadableFormat,
9
+ )
10
+
11
+ from dodal.devices.insertion_device import MAXIMUM_MOVE_TIME, Apple2Controller
12
+ from dodal.devices.insertion_device.enum import Pol
13
+ from dodal.log import LOGGER
14
+
15
+
16
+ class InsertionDevicePolarisation(StandardReadable, Locatable[Pol]):
17
+ """Apple2 ID polarisation movable device."""
18
+
19
+ def __init__(self, id_controller: Apple2Controller, name: str = "") -> None:
20
+ self.polarisation = Reference(id_controller.polarisation)
21
+ self.polarisation_setpoint = Reference(id_controller.polarisation_setpoint)
22
+ super().__init__(name=name)
23
+
24
+ self.add_readables([self.polarisation()], StandardReadableFormat.HINTED_SIGNAL)
25
+
26
+ @AsyncStatus.wrap
27
+ async def set(self, pol: Pol) -> None:
28
+ LOGGER.info(f"Setting insertion device polarisation to {pol.name}")
29
+ await self.polarisation().set(pol, timeout=MAXIMUM_MOVE_TIME)
30
+
31
+ async def locate(self) -> Location[Pol]:
32
+ """Return the current polarisation"""
33
+ setpoint, readback = await asyncio.gather(
34
+ self.polarisation_setpoint().get_value(), self.polarisation().get_value()
35
+ )
36
+ return Location(setpoint=setpoint, readback=readback)
@@ -1,13 +1,17 @@
1
+ import asyncio
1
2
  from enum import IntEnum
2
3
 
3
4
  from bluesky.protocols import Movable
4
5
  from ophyd_async.core import (
5
6
  DEFAULT_TIMEOUT,
6
7
  AsyncStatus,
8
+ DeviceMock,
9
+ DeviceVector,
7
10
  LazyMock,
8
11
  SignalR,
9
12
  SignalRW,
10
13
  StandardReadable,
14
+ default_mock_class,
11
15
  derived_signal_r,
12
16
  soft_signal_rw,
13
17
  )
@@ -22,6 +26,7 @@ from dodal.devices.oav.oav_parameters import (
22
26
  )
23
27
  from dodal.devices.oav.snapshots.snapshot import Snapshot
24
28
  from dodal.devices.oav.snapshots.snapshot_with_grid import SnapshotWithGrid
29
+ from dodal.log import LOGGER
25
30
 
26
31
 
27
32
  class Coords(IntEnum):
@@ -56,6 +61,35 @@ class NullZoomController(BaseZoomController):
56
61
  await self.level.set(value, wait=True)
57
62
 
58
63
 
64
+ class BeamCentreForZoom(StandardReadable):
65
+ """These PVs hold the beam centre on the OAV at each zoom level.
66
+
67
+ When the zoom level is changed the IOC will update the OAV overlay PVs to be at these positions."""
68
+
69
+ def __init__(
70
+ self, prefix: str, level_name_pv_suffix: str, centre_value_pv_suffix: str
71
+ ) -> None:
72
+ self.level_name = epics_signal_r(
73
+ str, f"{prefix}MP:SELECT.{level_name_pv_suffix}"
74
+ )
75
+ self.x_centre = epics_signal_rw(
76
+ float, f"{prefix}PBCX:VAL{centre_value_pv_suffix}"
77
+ )
78
+ self.y_centre = epics_signal_rw(
79
+ float, f"{prefix}PBCY:VAL{centre_value_pv_suffix}"
80
+ )
81
+ super().__init__()
82
+
83
+
84
+ class InstantMovingZoom(DeviceMock["ZoomController"]):
85
+ """Mock behaviour that instantly moves the zoom."""
86
+
87
+ async def connect(self, device: "ZoomController") -> None:
88
+ """Mock signals to do an instant move on setpoint write."""
89
+ device.DELAY_BETWEEN_MOTORS_AND_IMAGE_UPDATING_S = 0.001 # type:ignore
90
+
91
+
92
+ @default_mock_class(InstantMovingZoom)
59
93
  class ZoomController(BaseZoomController):
60
94
  """
61
95
  Device to control the zoom level. This should be set like
@@ -63,19 +97,49 @@ class ZoomController(BaseZoomController):
63
97
  oav.zoom_controller.set("1.0x")
64
98
 
65
99
  Note that changing the zoom may change the AD wiring on the associated OAV, as such
66
- you should wait on any zoom changs to finish before changing the OAV wiring.
100
+ you should wait on any zoom changes to finish before changing the OAV wiring.
67
101
  """
68
102
 
103
+ DELAY_BETWEEN_MOTORS_AND_IMAGE_UPDATING_S = 2
104
+
69
105
  def __init__(self, prefix: str, name: str = "") -> None:
70
106
  self.percentage = epics_signal_rw(float, f"{prefix}ZOOMPOSCMD")
71
107
 
72
108
  # Level is the string description of the zoom level e.g. "1.0x" or "1.0"
73
109
  self.level = epics_signal_rw(str, f"{prefix}MP:SELECT")
110
+
74
111
  super().__init__(name=name)
75
112
 
76
113
  @AsyncStatus.wrap
77
114
  async def set(self, value: str):
78
115
  await self.level.set(value, wait=True)
116
+ LOGGER.info(
117
+ "Waiting {self.DELAY_BETWEEN_MOTORS_AND_IMAGE_UPDATING_S} seconds for zoom to be noticeable"
118
+ )
119
+ await asyncio.sleep(self.DELAY_BETWEEN_MOTORS_AND_IMAGE_UPDATING_S)
120
+
121
+
122
+ class ZoomControllerWithBeamCentres(ZoomController):
123
+ def __init__(self, prefix: str, name: str = "") -> None:
124
+ level_to_centre_mapping = [
125
+ ("ZRST", "A"),
126
+ ("ONST", "B"),
127
+ ("TWST", "C"),
128
+ ("THST", "D"),
129
+ ("FRST", "E"),
130
+ ("FVST", "F"),
131
+ ("SXST", "G"),
132
+ ("SVST", "H"),
133
+ ]
134
+
135
+ self.beam_centres = DeviceVector(
136
+ {
137
+ i: BeamCentreForZoom(prefix, *level_to_centre_mapping[i])
138
+ for i in range(len(level_to_centre_mapping))
139
+ }
140
+ )
141
+
142
+ super().__init__(prefix, name)
79
143
 
80
144
 
81
145
  class OAV(StandardReadable):
@@ -118,6 +182,7 @@ class OAV(StandardReadable):
118
182
  self.zoom_controller = zoom_controller
119
183
 
120
184
  self.cam = Cam(f"{prefix}CAM:", name=name)
185
+
121
186
  with self.add_children_as_readables():
122
187
  self.grid_snapshot = SnapshotWithGrid(
123
188
  f"{prefix}{mjpeg_prefix}:", name, mjpg_x_size_pv, mjpg_y_size_pv
@@ -2,6 +2,7 @@ from collections.abc import Generator
2
2
  from enum import IntEnum
3
3
 
4
4
  import bluesky.plan_stubs as bps
5
+ import cv2
5
6
  import numpy as np
6
7
  from bluesky.utils import Msg
7
8
 
@@ -119,3 +120,19 @@ def wait_for_tip_to_be_found(
119
120
  raise PinNotFoundError(f"No pin found after {timeout} seconds")
120
121
 
121
122
  return Pixel((int(found_tip[0]), int(found_tip[1])))
123
+
124
+
125
+ def convert_to_gray_and_blur(data: cv2.typing.MatLike) -> cv2.typing.MatLike:
126
+ """
127
+ Preprocess the image array data (convert to grayscale and apply a gaussian blur)
128
+ Image is converted to grayscale (using a weighted mean as green contributes more to brightness)
129
+ as we aren't interested in data relating to colour. A blur is then applied to mitigate
130
+ errors due to rogue hot pixels.
131
+ """
132
+
133
+ # kernel size describes how many of the neighbouring pixels are used for the blur,
134
+ # higher kernal size means more of a blur effect
135
+ kernel_size = (7, 7)
136
+
137
+ gray_arr = cv2.cvtColor(data, cv2.COLOR_BGR2GRAY)
138
+ return cv2.GaussianBlur(gray_arr, kernel_size, 0)
dodal/devices/robot.py CHANGED
@@ -1,6 +1,7 @@
1
1
  import asyncio
2
2
  from asyncio import FIRST_COMPLETED, CancelledError, Task, wait_for
3
3
  from dataclasses import dataclass
4
+ from enum import IntEnum
4
5
 
5
6
  from bluesky.protocols import Movable
6
7
  from ophyd_async.core import (
@@ -21,8 +22,8 @@ from ophyd_async.epics.core import (
21
22
 
22
23
  from dodal.log import LOGGER
23
24
 
24
- WAIT_FOR_OLD_PIN_MSG = "Waiting on old pin unloaded"
25
- WAIT_FOR_NEW_PIN_MSG = "Waiting on new pin loaded"
25
+ WAIT_FOR_BEAMLINE_DISABLE_MSG = "Waiting on beamline disable"
26
+ WAIT_FOR_BEAMLINE_ENABLE_MSG = "Waiting on beamline enable"
26
27
 
27
28
 
28
29
  class RobotLoadError(Exception):
@@ -43,11 +44,19 @@ class SampleLocation:
43
44
  pin: int
44
45
 
45
46
 
47
+ SAMPLE_LOCATION_EMPTY = SampleLocation(-1, -1)
48
+
49
+
46
50
  class PinMounted(StrictEnum):
47
51
  NO_PIN_MOUNTED = "No Pin Mounted"
48
52
  PIN_MOUNTED = "Pin Mounted"
49
53
 
50
54
 
55
+ class BeamlineStatus(IntEnum):
56
+ ENABLED = 0
57
+ DISABLED = 1
58
+
59
+
51
60
  class ErrorStatus(Device):
52
61
  def __init__(self, prefix: str) -> None:
53
62
  self.str = epics_signal_r(str, prefix + "_ERR_MSG")
@@ -61,7 +70,7 @@ class ErrorStatus(Device):
61
70
  raise RobotLoadError(int(error_code), error_string) from raise_from
62
71
 
63
72
 
64
- class BartRobot(StandardReadable, Movable[SampleLocation | None]):
73
+ class BartRobot(StandardReadable, Movable[SampleLocation]):
65
74
  """The sample changing robot."""
66
75
 
67
76
  # How long to wait for the robot if it is busy soaking/drying
@@ -86,6 +95,8 @@ class BartRobot(StandardReadable, Movable[SampleLocation | None]):
86
95
  self.current_puck = epics_signal_r(float, prefix + "CURRENT_PUCK_RBV")
87
96
  self.current_pin = epics_signal_r(float, prefix + "CURRENT_PIN_RBV")
88
97
 
98
+ self.beamline_disabled = epics_signal_r(int, prefix + "ROBOT_OP_16_BITS.B8")
99
+
89
100
  self.next_pin = epics_signal_rw_rbv(float, prefix + "NEXT_PIN")
90
101
  self.next_puck = epics_signal_rw_rbv(float, prefix + "NEXT_PUCK")
91
102
 
@@ -116,8 +127,8 @@ class BartRobot(StandardReadable, Movable[SampleLocation | None]):
116
127
  )
117
128
  super().__init__(name=name)
118
129
 
119
- async def pin_state_or_error(self, expected_state=PinMounted.PIN_MOUNTED):
120
- """This co-routine will finish when either the pin sensor reaches the specified
130
+ async def beamline_status_or_error(self, expected_state: BeamlineStatus):
131
+ """This co-routine will finish when either the beamline reaches the specified
121
132
  state or the robot gives an error (whichever happens first). In the case where
122
133
  there is an error a RobotLoadError error is raised.
123
134
  """
@@ -130,12 +141,12 @@ class BartRobot(StandardReadable, Movable[SampleLocation | None]):
130
141
  error_msg = await self.prog_error.str.get_value()
131
142
  raise RobotLoadError(error_code, error_msg)
132
143
 
133
- async def wfv():
134
- await wait_for_value(self.gonio_pin_sensor, expected_state, None)
144
+ async def wait_for_expected_state():
145
+ await wait_for_value(self.beamline_disabled, expected_state.value, None)
135
146
 
136
147
  tasks = [
137
148
  (Task(raise_if_error())),
138
- (Task(wfv())),
149
+ (Task(wait_for_expected_state())),
139
150
  ]
140
151
  try:
141
152
  finished, unfinished = await asyncio.wait(
@@ -171,31 +182,37 @@ class BartRobot(StandardReadable, Movable[SampleLocation | None]):
171
182
  set_and_wait_for_value(self.next_pin, sample_location.pin),
172
183
  )
173
184
  await self.load.trigger()
174
- if await self.gonio_pin_sensor.get_value() == PinMounted.PIN_MOUNTED:
175
- LOGGER.info(WAIT_FOR_OLD_PIN_MSG)
176
- await self.pin_state_or_error(PinMounted.NO_PIN_MOUNTED)
177
- LOGGER.info(WAIT_FOR_NEW_PIN_MSG)
185
+ await self._wait_for_beamline_enabled_after_load_or_unload()
178
186
 
179
- await self.pin_state_or_error()
187
+ async def _wait_for_beamline_enabled_after_load_or_unload(self):
188
+ if await self.beamline_disabled.get_value() == BeamlineStatus.ENABLED.value:
189
+ LOGGER.info(WAIT_FOR_BEAMLINE_DISABLE_MSG)
190
+ await self.beamline_status_or_error(BeamlineStatus.DISABLED)
191
+
192
+ LOGGER.info(WAIT_FOR_BEAMLINE_ENABLE_MSG)
193
+ await self.beamline_status_or_error(BeamlineStatus.ENABLED)
180
194
 
181
195
  @AsyncStatus.wrap
182
- async def set(self, value: SampleLocation | None):
196
+ async def set(self, value: SampleLocation):
183
197
  """
184
198
  Perform a sample load from the specified sample location
185
199
  Args:
186
- value: The pin and puck to load, or None to unload the sample.
200
+ value: The pin and puck to load, or SAMPLE_LOCATION_EMPTY to unload the sample.
187
201
  Raises:
188
- RobotLoadError if a timeout occurs, or if an error occurs loading the smaple.
202
+ RobotLoadError if a timeout occurs, or if an error occurs loading the sample.
189
203
  """
190
204
  try:
191
- if value is not None:
205
+ if value != SAMPLE_LOCATION_EMPTY:
192
206
  await wait_for(
193
207
  self._load_pin_and_puck(value),
194
208
  timeout=self.LOAD_TIMEOUT + self.NOT_BUSY_TIMEOUT,
195
209
  )
196
210
  else:
197
211
  await self.unload.trigger(timeout=self.LOAD_TIMEOUT)
198
- await wait_for_value(self.program_running, False, self.NOT_BUSY_TIMEOUT)
212
+ await wait_for(
213
+ self._wait_for_beamline_enabled_after_load_or_unload(),
214
+ timeout=self.LOAD_TIMEOUT + self.NOT_BUSY_TIMEOUT,
215
+ )
199
216
  except TimeoutError as e:
200
217
  await self.prog_error.raise_if_error(e)
201
218
  await self.controller_error.raise_if_error(e)
@@ -0,0 +1,38 @@
1
+ from typing import TypeVar
2
+
3
+ from bluesky.protocols import Movable
4
+ from ophyd_async.core import AsyncStatus, StandardReadable, StrictEnum, soft_signal_rw
5
+
6
+
7
+ class SelectedSource(StrictEnum):
8
+ SOURCE1 = "source1"
9
+ SOURCE2 = "source2"
10
+
11
+
12
+ T = TypeVar("T")
13
+
14
+
15
+ def get_obj_from_selected_source(selected_source: SelectedSource, s1: T, s2: T) -> T:
16
+ """Util function that maps enum values for SelectedSource to two objects. It then
17
+ returns one of the objects that corrosponds to the selected_source value."""
18
+ match selected_source:
19
+ case SelectedSource.SOURCE1:
20
+ return s1
21
+ case SelectedSource.SOURCE2:
22
+ return s2
23
+
24
+
25
+ class SourceSelector(StandardReadable, Movable[SelectedSource]):
26
+ """Device that holds a selected_source signal enum of SelectedSource. Useful for
27
+ beamlines with multiple sources to coordinate which energy source or shutter to use."""
28
+
29
+ def __init__(self, name: str = ""):
30
+ with self.add_children_as_readables():
31
+ self.selected_source = soft_signal_rw(
32
+ SelectedSource, SelectedSource.SOURCE1
33
+ )
34
+ super().__init__(name)
35
+
36
+ @AsyncStatus.wrap
37
+ async def set(self, value: SelectedSource):
38
+ await self.selected_source.set(value)