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.
- {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/METADATA +2 -32
- {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/RECORD +79 -71
- dodal/_version.py +2 -2
- dodal/beamlines/adsim.py +30 -23
- dodal/beamlines/b07.py +1 -1
- dodal/beamlines/b07_1.py +1 -1
- dodal/beamlines/i02_1.py +14 -42
- dodal/beamlines/i02_2.py +5 -11
- dodal/beamlines/i03.py +4 -1
- dodal/beamlines/i03_supervisor.py +19 -0
- dodal/beamlines/i04.py +74 -179
- dodal/beamlines/i05.py +9 -1
- dodal/beamlines/i06.py +1 -1
- dodal/beamlines/i06_1.py +24 -0
- dodal/beamlines/i09.py +53 -9
- dodal/beamlines/i09_1.py +9 -1
- dodal/beamlines/i09_2.py +7 -6
- dodal/beamlines/i10_optics.py +1 -1
- dodal/beamlines/i16.py +34 -0
- dodal/beamlines/i17.py +1 -1
- dodal/beamlines/i20_1.py +14 -0
- dodal/beamlines/i21.py +71 -4
- dodal/beamlines/i23.py +19 -25
- dodal/beamlines/i24.py +55 -105
- dodal/beamlines/p60.py +12 -2
- dodal/common/__init__.py +2 -1
- dodal/common/maths.py +80 -0
- dodal/devices/eiger.py +44 -23
- dodal/devices/electron_analyser/__init__.py +0 -33
- dodal/devices/electron_analyser/base/__init__.py +58 -0
- dodal/devices/electron_analyser/base/base_controller.py +84 -0
- dodal/devices/electron_analyser/base/base_detector.py +214 -0
- dodal/devices/electron_analyser/{abstract → base}/base_driver_io.py +23 -42
- dodal/devices/electron_analyser/{enums.py → base/base_enums.py} +0 -5
- dodal/devices/electron_analyser/{abstract → base}/base_region.py +48 -11
- dodal/devices/electron_analyser/{util.py → base/base_util.py} +1 -1
- dodal/devices/electron_analyser/{energy_sources.py → base/energy_sources.py} +27 -26
- dodal/devices/electron_analyser/specs/__init__.py +4 -4
- dodal/devices/electron_analyser/specs/specs_detector.py +47 -0
- dodal/devices/electron_analyser/specs/{driver_io.py → specs_driver_io.py} +23 -26
- dodal/devices/electron_analyser/specs/{region.py → specs_region.py} +4 -3
- dodal/devices/electron_analyser/vgscienta/__init__.py +4 -4
- dodal/devices/electron_analyser/vgscienta/vgscienta_detector.py +53 -0
- dodal/devices/electron_analyser/vgscienta/{driver_io.py → vgscienta_driver_io.py} +25 -31
- dodal/devices/electron_analyser/vgscienta/{region.py → vgscienta_region.py} +6 -6
- dodal/devices/fast_shutter.py +108 -25
- dodal/devices/i04/beam_centre.py +84 -0
- dodal/devices/i04/max_pixel.py +4 -17
- dodal/devices/i04/murko_results.py +18 -3
- dodal/devices/i09_2_shared/i09_apple2.py +0 -72
- dodal/devices/i10/i10_apple2.py +7 -7
- dodal/devices/i17/i17_apple2.py +6 -6
- dodal/devices/i21/__init__.py +3 -1
- dodal/devices/i24/commissioning_jungfrau.py +9 -10
- dodal/devices/insertion_device/__init__.py +62 -0
- dodal/devices/insertion_device/apple2_controller.py +380 -0
- dodal/devices/insertion_device/apple2_undulator.py +152 -481
- dodal/devices/insertion_device/energy.py +88 -0
- dodal/devices/insertion_device/energy_motor_lookup.py +1 -1
- dodal/devices/insertion_device/enum.py +17 -0
- dodal/devices/insertion_device/lookup_table_models.py +66 -36
- dodal/devices/insertion_device/polarisation.py +36 -0
- dodal/devices/oav/oav_detector.py +66 -1
- dodal/devices/oav/utils.py +17 -0
- dodal/devices/robot.py +35 -18
- dodal/devices/selectable_source.py +38 -0
- dodal/devices/zebra/zebra.py +15 -0
- dodal/devices/zebra/zebra_constants_mapping.py +1 -0
- dodal/plans/configure_arm_trigger_and_disarm_detector.py +0 -1
- dodal/testing/__init__.py +0 -0
- dodal/testing/electron_analyser/device_factory.py +4 -4
- dodal/testing/fixtures/devices/apple2.py +1 -1
- dodal/testing/fixtures/run_engine.py +4 -0
- dodal/devices/electron_analyser/abstract/__init__.py +0 -25
- dodal/devices/electron_analyser/abstract/base_detector.py +0 -63
- dodal/devices/electron_analyser/abstract/types.py +0 -12
- dodal/devices/electron_analyser/detector.py +0 -143
- dodal/devices/electron_analyser/specs/detector.py +0 -34
- dodal/devices/electron_analyser/types.py +0 -57
- dodal/devices/electron_analyser/vgscienta/detector.py +0 -48
- {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/WHEEL +0 -0
- {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/entry_points.txt +0 -0
- {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/licenses/LICENSE +0 -0
- {dls_dodal-1.67.0.dist-info → dls_dodal-1.69.0.dist-info}/top_level.txt +0 -0
- /dodal/devices/electron_analyser/specs/{enums.py → specs_enums.py} +0 -0
- /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.
|
|
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.
|
|
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(
|
|
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
|
-
|
|
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
|
|
160
|
+
return self.energy_entries[0].min_energy
|
|
146
161
|
|
|
147
162
|
@property
|
|
148
163
|
def max_energy(self) -> float:
|
|
149
|
-
return
|
|
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
|
-
|
|
161
|
-
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
199
|
-
|
|
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]
|
|
244
|
-
"""Process a single row from the CSV file and update the
|
|
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
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
271
|
-
|
|
296
|
+
process_row(row=row)
|
|
272
297
|
# Check if our LookupTable is empty after processing, raise error if it is.
|
|
273
|
-
if not
|
|
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
|
-
|
|
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
|
|
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
|
dodal/devices/oav/utils.py
CHANGED
|
@@ -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
|
-
|
|
25
|
-
|
|
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
|
|
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
|
|
120
|
-
"""This co-routine will finish when either the
|
|
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
|
|
134
|
-
await wait_for_value(self.
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
202
|
+
RobotLoadError if a timeout occurs, or if an error occurs loading the sample.
|
|
189
203
|
"""
|
|
190
204
|
try:
|
|
191
|
-
if value
|
|
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
|
|
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)
|