dls-dodal 1.31.1__py3-none-any.whl → 1.33.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.31.1.dist-info → dls_dodal-1.33.0.dist-info}/METADATA +3 -2
- {dls_dodal-1.31.1.dist-info → dls_dodal-1.33.0.dist-info}/RECORD +26 -24
- {dls_dodal-1.31.1.dist-info → dls_dodal-1.33.0.dist-info}/WHEEL +1 -1
- dodal/_version.py +2 -2
- dodal/beamlines/b01_1.py +77 -0
- dodal/beamlines/i03.py +1 -1
- dodal/beamlines/i04.py +1 -0
- dodal/beamlines/i22.py +1 -0
- dodal/common/signal_utils.py +53 -0
- dodal/devices/aperturescatterguard.py +22 -25
- dodal/devices/areadetector/plugins/MJPG.py +32 -8
- dodal/devices/fast_grid_scan.py +2 -1
- dodal/devices/focusing_mirror.py +4 -5
- dodal/devices/i24/pmac.py +77 -15
- dodal/devices/oav/oav_detector.py +1 -15
- dodal/devices/robot.py +18 -6
- dodal/devices/tetramm.py +11 -16
- dodal/devices/undulator.py +91 -2
- dodal/devices/undulator_dcm.py +3 -57
- dodal/devices/webcam.py +33 -7
- dodal/devices/zocalo/zocalo_interaction.py +6 -1
- dodal/devices/zocalo/zocalo_results.py +114 -10
- dodal/utils.py +4 -3
- {dls_dodal-1.31.1.dist-info → dls_dodal-1.33.0.dist-info}/LICENSE +0 -0
- {dls_dodal-1.31.1.dist-info → dls_dodal-1.33.0.dist-info}/entry_points.txt +0 -0
- {dls_dodal-1.31.1.dist-info → dls_dodal-1.33.0.dist-info}/top_level.txt +0 -0
dodal/devices/i24/pmac.py
CHANGED
|
@@ -1,16 +1,17 @@
|
|
|
1
|
+
from asyncio import sleep
|
|
1
2
|
from enum import Enum, IntEnum
|
|
2
|
-
from typing import SupportsFloat
|
|
3
3
|
|
|
4
|
-
from bluesky.protocols import Triggerable
|
|
4
|
+
from bluesky.protocols import Flyable, Triggerable
|
|
5
5
|
from ophyd_async.core import (
|
|
6
|
+
CALCULATE_TIMEOUT,
|
|
6
7
|
DEFAULT_TIMEOUT,
|
|
7
8
|
AsyncStatus,
|
|
8
|
-
CalculateTimeout,
|
|
9
9
|
SignalBackend,
|
|
10
10
|
SignalR,
|
|
11
11
|
SignalRW,
|
|
12
12
|
SoftSignalBackend,
|
|
13
13
|
StandardReadable,
|
|
14
|
+
soft_signal_rw,
|
|
14
15
|
wait_for_value,
|
|
15
16
|
)
|
|
16
17
|
from ophyd_async.epics.motor import Motor
|
|
@@ -89,7 +90,7 @@ class PMACStringLaser(SignalRW):
|
|
|
89
90
|
self,
|
|
90
91
|
value: LaserSettings,
|
|
91
92
|
wait=True,
|
|
92
|
-
timeout=
|
|
93
|
+
timeout=CALCULATE_TIMEOUT,
|
|
93
94
|
):
|
|
94
95
|
await self.signal.set(value.value, wait, timeout)
|
|
95
96
|
|
|
@@ -112,13 +113,13 @@ class PMACStringEncReset(SignalRW):
|
|
|
112
113
|
self,
|
|
113
114
|
value: EncReset,
|
|
114
115
|
wait=True,
|
|
115
|
-
timeout=
|
|
116
|
+
timeout=CALCULATE_TIMEOUT,
|
|
116
117
|
):
|
|
117
118
|
await self.signal.set(value.value, wait, timeout)
|
|
118
119
|
|
|
119
120
|
|
|
120
|
-
class ProgramRunner(SignalRW):
|
|
121
|
-
"""
|
|
121
|
+
class ProgramRunner(SignalRW, Flyable):
|
|
122
|
+
"""Run the collection by setting the program number on the PMAC string.
|
|
122
123
|
|
|
123
124
|
Once the program number has been set, wait for the collection to be complete.
|
|
124
125
|
This will only be true when the status becomes 0.
|
|
@@ -128,22 +129,73 @@ class ProgramRunner(SignalRW):
|
|
|
128
129
|
self,
|
|
129
130
|
pmac_str_sig: SignalRW,
|
|
130
131
|
status_sig: SignalR,
|
|
132
|
+
prog_num_sig: SignalRW,
|
|
133
|
+
collection_time_sig: SignalRW,
|
|
131
134
|
backend: SignalBackend,
|
|
132
135
|
timeout: float | None = DEFAULT_TIMEOUT,
|
|
133
136
|
name: str = "",
|
|
134
137
|
) -> None:
|
|
135
138
|
self.signal = pmac_str_sig
|
|
136
139
|
self.status = status_sig
|
|
140
|
+
self.prog_num = prog_num_sig
|
|
141
|
+
|
|
142
|
+
self.collection_time = collection_time_sig
|
|
143
|
+
self.KICKOFF_TIMEOUT = timeout
|
|
144
|
+
|
|
137
145
|
super().__init__(backend, timeout, name)
|
|
138
146
|
|
|
147
|
+
async def _get_prog_number_string(self) -> str:
|
|
148
|
+
prog_num = await self.prog_num.get_value()
|
|
149
|
+
return f"&2b{prog_num}r"
|
|
150
|
+
|
|
151
|
+
@AsyncStatus.wrap
|
|
152
|
+
async def kickoff(self):
|
|
153
|
+
"""Kick off the collection by sending a program number to the pmac_string and \
|
|
154
|
+
wait for the scan status PV to go to 1.
|
|
155
|
+
"""
|
|
156
|
+
prog_num_str = await self._get_prog_number_string()
|
|
157
|
+
await self.signal.set(prog_num_str, wait=True)
|
|
158
|
+
await wait_for_value(
|
|
159
|
+
self.status,
|
|
160
|
+
ScanState.RUNNING,
|
|
161
|
+
timeout=self.KICKOFF_TIMEOUT,
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
@AsyncStatus.wrap
|
|
165
|
+
async def complete(self):
|
|
166
|
+
"""Stop collecting when the scan status PV goes to 0.
|
|
167
|
+
|
|
168
|
+
Args:
|
|
169
|
+
complete_time (float): total time required by the collection to \
|
|
170
|
+
finish correctly.
|
|
171
|
+
"""
|
|
172
|
+
scan_complete_time = await self.collection_time.get_value()
|
|
173
|
+
await wait_for_value(self.status, ScanState.DONE, timeout=scan_complete_time)
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
class ProgramAbort(Triggerable):
|
|
177
|
+
"""Abort a data collection by setting the PMAC string and then wait for the \
|
|
178
|
+
status value to go back to 0.
|
|
179
|
+
"""
|
|
180
|
+
|
|
181
|
+
def __init__(
|
|
182
|
+
self,
|
|
183
|
+
pmac_str_sig: SignalRW,
|
|
184
|
+
status_sig: SignalR,
|
|
185
|
+
) -> None:
|
|
186
|
+
self.signal = pmac_str_sig
|
|
187
|
+
self.status = status_sig
|
|
188
|
+
|
|
139
189
|
@AsyncStatus.wrap
|
|
140
|
-
async def
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
190
|
+
async def trigger(self):
|
|
191
|
+
await self.signal.set("A", wait=True)
|
|
192
|
+
await sleep(1.0) # TODO Check with scientist what this sleep is really for.
|
|
193
|
+
await self.signal.set("P2401=0", wait=True)
|
|
194
|
+
await wait_for_value(
|
|
195
|
+
self.status,
|
|
196
|
+
ScanState.DONE,
|
|
197
|
+
timeout=DEFAULT_TIMEOUT,
|
|
198
|
+
)
|
|
147
199
|
|
|
148
200
|
|
|
149
201
|
class PMAC(StandardReadable):
|
|
@@ -172,8 +224,18 @@ class PMAC(StandardReadable):
|
|
|
172
224
|
self.scanstatus = epics_signal_r(float, "BL24I-MO-STEP-14:signal:P2401")
|
|
173
225
|
self.counter = epics_signal_r(float, "BL24I-MO-STEP-14:signal:P2402")
|
|
174
226
|
|
|
227
|
+
# A couple of soft signals for running a collection: program number to send to
|
|
228
|
+
# the PMAC_STRING and expected collection time.
|
|
229
|
+
self.program_number = soft_signal_rw(int)
|
|
230
|
+
self.collection_time = soft_signal_rw(float, initial_value=600.0, units="s")
|
|
231
|
+
|
|
175
232
|
self.run_program = ProgramRunner(
|
|
176
|
-
self.pmac_string,
|
|
233
|
+
self.pmac_string,
|
|
234
|
+
self.scanstatus,
|
|
235
|
+
self.program_number,
|
|
236
|
+
self.collection_time,
|
|
237
|
+
backend=SoftSignalBackend(str),
|
|
177
238
|
)
|
|
239
|
+
self.abort_program = ProgramAbort(self.pmac_string, self.scanstatus)
|
|
178
240
|
|
|
179
241
|
super().__init__(name)
|
|
@@ -12,7 +12,6 @@ from ophyd import (
|
|
|
12
12
|
OverlayPlugin,
|
|
13
13
|
ProcessPlugin,
|
|
14
14
|
ROIPlugin,
|
|
15
|
-
Signal,
|
|
16
15
|
StatusBase,
|
|
17
16
|
)
|
|
18
17
|
|
|
@@ -35,8 +34,6 @@ class ZoomController(Device):
|
|
|
35
34
|
|
|
36
35
|
# Level is the string description of the zoom level e.g. "1.0x"
|
|
37
36
|
level = Component(EpicsSignal, "MP:SELECT", string=True)
|
|
38
|
-
# Used by OAV to work out if we're changing the setpoint
|
|
39
|
-
_level_sp = Component(Signal)
|
|
40
37
|
|
|
41
38
|
zrst = Component(EpicsSignal, "MP:SELECT.ZRST")
|
|
42
39
|
onst = Component(EpicsSignal, "MP:SELECT.ONST")
|
|
@@ -46,14 +43,6 @@ class ZoomController(Device):
|
|
|
46
43
|
fvst = Component(EpicsSignal, "MP:SELECT.FVST")
|
|
47
44
|
sxst = Component(EpicsSignal, "MP:SELECT.SXST")
|
|
48
45
|
|
|
49
|
-
def set_flatfield_on_zoom_level_one(self, value):
|
|
50
|
-
self.parent: OAV
|
|
51
|
-
flat_applied = self.parent.proc.port_name.get()
|
|
52
|
-
no_flat_applied = self.parent.cam.port_name.get()
|
|
53
|
-
return self.parent.grid_snapshot.input_plugin.set(
|
|
54
|
-
flat_applied if value == "1.0x" else no_flat_applied
|
|
55
|
-
)
|
|
56
|
-
|
|
57
46
|
@property
|
|
58
47
|
def allowed_zoom_levels(self):
|
|
59
48
|
return [
|
|
@@ -67,10 +56,7 @@ class ZoomController(Device):
|
|
|
67
56
|
]
|
|
68
57
|
|
|
69
58
|
def set(self, level_to_set: str) -> StatusBase:
|
|
70
|
-
|
|
71
|
-
return_status &= self.level.set(level_to_set)
|
|
72
|
-
return_status &= self.set_flatfield_on_zoom_level_one(level_to_set)
|
|
73
|
-
return return_status
|
|
59
|
+
return self.level.set(level_to_set)
|
|
74
60
|
|
|
75
61
|
|
|
76
62
|
class OAV(AreaDetector):
|
dodal/devices/robot.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
from asyncio import FIRST_COMPLETED, CancelledError, Task
|
|
2
|
+
from asyncio import FIRST_COMPLETED, CancelledError, Task, wait_for
|
|
3
3
|
from dataclasses import dataclass
|
|
4
4
|
from enum import Enum
|
|
5
5
|
|
|
@@ -41,6 +41,9 @@ class PinMounted(str, Enum):
|
|
|
41
41
|
class BartRobot(StandardReadable, Movable):
|
|
42
42
|
"""The sample changing robot."""
|
|
43
43
|
|
|
44
|
+
# How long to wait for the robot if it is busy soaking/drying
|
|
45
|
+
NOT_BUSY_TIMEOUT = 60
|
|
46
|
+
# How long to wait for the actual load to happen
|
|
44
47
|
LOAD_TIMEOUT = 60
|
|
45
48
|
NO_PIN_ERROR_CODE = 25
|
|
46
49
|
|
|
@@ -54,10 +57,15 @@ class BartRobot(StandardReadable, Movable):
|
|
|
54
57
|
) -> None:
|
|
55
58
|
self.barcode = epics_signal_r(str, prefix + "BARCODE")
|
|
56
59
|
self.gonio_pin_sensor = epics_signal_r(PinMounted, prefix + "PIN_MOUNTED")
|
|
60
|
+
|
|
57
61
|
self.next_pin = epics_signal_rw_rbv(float, prefix + "NEXT_PIN")
|
|
58
62
|
self.next_puck = epics_signal_rw_rbv(float, prefix + "NEXT_PUCK")
|
|
63
|
+
self.current_puck = epics_signal_r(float, prefix + "CURRENT_PUCK_RBV")
|
|
64
|
+
self.current_pin = epics_signal_r(float, prefix + "CURRENT_PIN_RBV")
|
|
65
|
+
|
|
59
66
|
self.next_sample_id = epics_signal_rw_rbv(float, prefix + "NEXT_ID")
|
|
60
67
|
self.sample_id = epics_signal_r(float, prefix + "CURRENT_ID_RBV")
|
|
68
|
+
|
|
61
69
|
self.load = epics_signal_x(prefix + "LOAD.PROC")
|
|
62
70
|
self.program_running = epics_signal_r(bool, prefix + "PROGRAM_RUNNING")
|
|
63
71
|
self.program_name = epics_signal_r(str, prefix + "PROGRAM_NAME")
|
|
@@ -93,7 +101,7 @@ class BartRobot(StandardReadable, Movable):
|
|
|
93
101
|
for task in finished:
|
|
94
102
|
await task
|
|
95
103
|
except CancelledError:
|
|
96
|
-
# If the outer enclosing task cancels after
|
|
104
|
+
# If the outer enclosing task cancels after a timeout, this causes CancelledError to be raised
|
|
97
105
|
# in the current task, when it propagates to here we should cancel all pending tasks before bubbling up
|
|
98
106
|
for task in tasks:
|
|
99
107
|
task.cancel()
|
|
@@ -105,7 +113,9 @@ class BartRobot(StandardReadable, Movable):
|
|
|
105
113
|
LOGGER.info(
|
|
106
114
|
f"Waiting on robot to finish {await self.program_name.get_value()}"
|
|
107
115
|
)
|
|
108
|
-
await wait_for_value(
|
|
116
|
+
await wait_for_value(
|
|
117
|
+
self.program_running, False, timeout=self.NOT_BUSY_TIMEOUT
|
|
118
|
+
)
|
|
109
119
|
await asyncio.gather(
|
|
110
120
|
set_and_wait_for_value(self.next_puck, sample_location.puck),
|
|
111
121
|
set_and_wait_for_value(self.next_pin, sample_location.pin),
|
|
@@ -121,10 +131,12 @@ class BartRobot(StandardReadable, Movable):
|
|
|
121
131
|
@AsyncStatus.wrap
|
|
122
132
|
async def set(self, value: SampleLocation):
|
|
123
133
|
try:
|
|
124
|
-
await
|
|
125
|
-
self._load_pin_and_puck(value),
|
|
134
|
+
await wait_for(
|
|
135
|
+
self._load_pin_and_puck(value),
|
|
136
|
+
timeout=self.LOAD_TIMEOUT + self.NOT_BUSY_TIMEOUT,
|
|
126
137
|
)
|
|
127
|
-
except asyncio.TimeoutError as e:
|
|
138
|
+
except (asyncio.TimeoutError, TimeoutError) as e:
|
|
139
|
+
# Will only need to catch asyncio.TimeoutError after https://github.com/bluesky/ophyd-async/issues/572
|
|
128
140
|
error_code = await self.error_code.get_value()
|
|
129
141
|
error_string = await self.error_str.get_value()
|
|
130
142
|
raise RobotLoadFailed(int(error_code), error_string) from e
|
dodal/devices/tetramm.py
CHANGED
|
@@ -3,13 +3,13 @@ from enum import Enum
|
|
|
3
3
|
|
|
4
4
|
from bluesky.protocols import Hints
|
|
5
5
|
from ophyd_async.core import (
|
|
6
|
-
AsyncStatus,
|
|
7
6
|
DatasetDescriber,
|
|
8
7
|
DetectorControl,
|
|
9
8
|
DetectorTrigger,
|
|
10
9
|
Device,
|
|
11
10
|
PathProvider,
|
|
12
11
|
StandardDetector,
|
|
12
|
+
TriggerInfo,
|
|
13
13
|
set_and_wait_for_value,
|
|
14
14
|
soft_signal_r_and_setter,
|
|
15
15
|
)
|
|
@@ -113,29 +113,24 @@ class TetrammController(DetectorControl):
|
|
|
113
113
|
# 2 internal clock cycles. Best effort approximation
|
|
114
114
|
return 2 / self.base_sample_rate
|
|
115
115
|
|
|
116
|
-
async def
|
|
117
|
-
self
|
|
118
|
-
|
|
119
|
-
trigger: DetectorTrigger = DetectorTrigger.edge_trigger,
|
|
120
|
-
exposure: float | None = None,
|
|
121
|
-
) -> AsyncStatus:
|
|
122
|
-
if exposure is None:
|
|
123
|
-
raise ValueError(
|
|
124
|
-
"Tetramm does not support arm without exposure time. "
|
|
125
|
-
"Is this a software scan? Tetramm only supports hardware scans."
|
|
126
|
-
)
|
|
127
|
-
self._validate_trigger(trigger)
|
|
116
|
+
async def prepare(self, trigger_info: TriggerInfo):
|
|
117
|
+
self._validate_trigger(trigger_info.trigger)
|
|
118
|
+
assert trigger_info.livetime is not None
|
|
128
119
|
|
|
129
120
|
# trigger mode must be set first and on its own!
|
|
130
121
|
await self._drv.trigger_mode.set(TetrammTrigger.ExtTrigger)
|
|
131
122
|
|
|
132
123
|
await asyncio.gather(
|
|
133
|
-
self._drv.averaging_time.set(
|
|
124
|
+
self._drv.averaging_time.set(trigger_info.livetime),
|
|
125
|
+
self.set_exposure(trigger_info.livetime),
|
|
134
126
|
)
|
|
135
127
|
|
|
136
|
-
|
|
128
|
+
async def arm(self):
|
|
129
|
+
self._arm_status = await set_and_wait_for_value(self._drv.acquire, True)
|
|
137
130
|
|
|
138
|
-
|
|
131
|
+
async def wait_for_idle(self):
|
|
132
|
+
if self._arm_status:
|
|
133
|
+
await self._arm_status
|
|
139
134
|
|
|
140
135
|
def _validate_trigger(self, trigger: DetectorTrigger) -> None:
|
|
141
136
|
supported_trigger_types = {
|
dodal/devices/undulator.py
CHANGED
|
@@ -1,12 +1,35 @@
|
|
|
1
1
|
from enum import Enum
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
import numpy as np
|
|
4
|
+
from bluesky.protocols import Movable
|
|
5
|
+
from numpy import argmin, ndarray
|
|
6
|
+
from ophyd_async.core import (
|
|
7
|
+
AsyncStatus,
|
|
8
|
+
ConfigSignal,
|
|
9
|
+
StandardReadable,
|
|
10
|
+
soft_signal_r_and_setter,
|
|
11
|
+
)
|
|
4
12
|
from ophyd_async.epics.motor import Motor
|
|
5
13
|
from ophyd_async.epics.signal import epics_signal_r
|
|
6
14
|
|
|
15
|
+
from dodal.log import LOGGER
|
|
16
|
+
|
|
17
|
+
from .util.lookup_tables import energy_distance_table
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class AccessError(Exception):
|
|
21
|
+
pass
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Enable to allow testing when the beamline is down, do not change in production!
|
|
25
|
+
TEST_MODE = False
|
|
26
|
+
# will be made more generic in https://github.com/DiamondLightSource/dodal/issues/754
|
|
27
|
+
|
|
28
|
+
|
|
7
29
|
# The acceptable difference, in mm, between the undulator gap and the DCM
|
|
8
30
|
# energy, when the latter is converted to mm using lookup tables
|
|
9
31
|
UNDULATOR_DISCREPANCY_THRESHOLD_MM = 2e-3
|
|
32
|
+
STATUS_TIMEOUT_S: float = 10.0
|
|
10
33
|
|
|
11
34
|
|
|
12
35
|
class UndulatorGapAccess(str, Enum):
|
|
@@ -14,7 +37,15 @@ class UndulatorGapAccess(str, Enum):
|
|
|
14
37
|
DISABLED = "DISABLED"
|
|
15
38
|
|
|
16
39
|
|
|
17
|
-
|
|
40
|
+
def _get_closest_gap_for_energy(
|
|
41
|
+
dcm_energy_ev: float, energy_to_distance_table: ndarray
|
|
42
|
+
) -> float:
|
|
43
|
+
table = energy_to_distance_table.transpose()
|
|
44
|
+
idx = argmin(np.abs(table[0] - dcm_energy_ev))
|
|
45
|
+
return table[1][idx]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class Undulator(StandardReadable, Movable):
|
|
18
49
|
"""
|
|
19
50
|
An Undulator-type insertion device, used to control photon emission at a given
|
|
20
51
|
beam energy.
|
|
@@ -23,6 +54,7 @@ class Undulator(StandardReadable):
|
|
|
23
54
|
def __init__(
|
|
24
55
|
self,
|
|
25
56
|
prefix: str,
|
|
57
|
+
id_gap_lookup_table_path: str,
|
|
26
58
|
name: str = "",
|
|
27
59
|
poles: int | None = None,
|
|
28
60
|
length: float | None = None,
|
|
@@ -36,6 +68,7 @@ class Undulator(StandardReadable):
|
|
|
36
68
|
name (str, optional): Name for device. Defaults to "".
|
|
37
69
|
"""
|
|
38
70
|
|
|
71
|
+
self.id_gap_lookup_table_path = id_gap_lookup_table_path
|
|
39
72
|
with self.add_children_as_readables():
|
|
40
73
|
self.gap_motor = Motor(prefix + "BLGAPMTR")
|
|
41
74
|
self.current_gap = epics_signal_r(float, prefix + "CURRGAPD")
|
|
@@ -63,3 +96,59 @@ class Undulator(StandardReadable):
|
|
|
63
96
|
self.length = None
|
|
64
97
|
|
|
65
98
|
super().__init__(name)
|
|
99
|
+
|
|
100
|
+
@AsyncStatus.wrap
|
|
101
|
+
async def set(self, value: float):
|
|
102
|
+
"""
|
|
103
|
+
Set the undulator gap to a given energy in keV
|
|
104
|
+
|
|
105
|
+
Args:
|
|
106
|
+
value: energy in keV
|
|
107
|
+
"""
|
|
108
|
+
await self._set_undulator_gap(value)
|
|
109
|
+
|
|
110
|
+
async def _set_undulator_gap(self, energy_kev: float) -> None:
|
|
111
|
+
access_level = await self.gap_access.get_value()
|
|
112
|
+
if access_level is UndulatorGapAccess.DISABLED and not TEST_MODE:
|
|
113
|
+
raise AccessError("Undulator gap access is disabled. Contact Control Room")
|
|
114
|
+
LOGGER.info(f"Setting undulator gap to {energy_kev:.2f} kev")
|
|
115
|
+
target_gap = await self._get_gap_to_match_energy(energy_kev)
|
|
116
|
+
|
|
117
|
+
# Check if undulator gap is close enough to the value from the DCM
|
|
118
|
+
current_gap = await self.current_gap.get_value()
|
|
119
|
+
tolerance = await self.gap_discrepancy_tolerance_mm.get_value()
|
|
120
|
+
difference = abs(target_gap - current_gap)
|
|
121
|
+
if difference > tolerance:
|
|
122
|
+
LOGGER.info(
|
|
123
|
+
f"Undulator gap mismatch. {difference:.3f}mm is outside tolerance.\
|
|
124
|
+
Moving gap to nominal value, {target_gap:.3f}mm"
|
|
125
|
+
)
|
|
126
|
+
if not TEST_MODE:
|
|
127
|
+
# Only move if the gap is sufficiently different to the value from the
|
|
128
|
+
# DCM lookup table AND we're not in TEST_MODE
|
|
129
|
+
await self.gap_motor.set(
|
|
130
|
+
target_gap,
|
|
131
|
+
timeout=STATUS_TIMEOUT_S,
|
|
132
|
+
)
|
|
133
|
+
else:
|
|
134
|
+
LOGGER.debug("In test mode, not moving ID gap")
|
|
135
|
+
else:
|
|
136
|
+
LOGGER.debug(
|
|
137
|
+
"Gap is already in the correct place for the new energy value "
|
|
138
|
+
f"{energy_kev}, no need to ask it to move"
|
|
139
|
+
)
|
|
140
|
+
|
|
141
|
+
async def _get_gap_to_match_energy(self, energy_kev: float) -> float:
|
|
142
|
+
"""
|
|
143
|
+
get a 2d np.array from lookup table that
|
|
144
|
+
converts energies to undulator gap distance
|
|
145
|
+
"""
|
|
146
|
+
energy_to_distance_table: np.ndarray = await energy_distance_table(
|
|
147
|
+
self.id_gap_lookup_table_path
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
# Use the lookup table to get the undulator gap associated with this dcm energy
|
|
151
|
+
return _get_closest_gap_for_energy(
|
|
152
|
+
energy_kev * 1000,
|
|
153
|
+
energy_to_distance_table,
|
|
154
|
+
)
|
dodal/devices/undulator_dcm.py
CHANGED
|
@@ -1,19 +1,14 @@
|
|
|
1
1
|
import asyncio
|
|
2
2
|
|
|
3
|
-
import numpy as np
|
|
4
3
|
from bluesky.protocols import Movable
|
|
5
|
-
from numpy import argmin, ndarray
|
|
6
4
|
from ophyd_async.core import AsyncStatus, StandardReadable
|
|
7
5
|
|
|
8
6
|
from dodal.common.beamlines.beamline_parameters import get_beamline_parameters
|
|
9
|
-
from dodal.log import LOGGER
|
|
10
7
|
|
|
11
8
|
from .dcm import DCM
|
|
12
9
|
from .undulator import Undulator, UndulatorGapAccess
|
|
13
|
-
from .util.lookup_tables import energy_distance_table
|
|
14
10
|
|
|
15
11
|
ENERGY_TIMEOUT_S: float = 30.0
|
|
16
|
-
STATUS_TIMEOUT_S: float = 10.0
|
|
17
12
|
|
|
18
13
|
# Enable to allow testing when the beamline is down, do not change in production!
|
|
19
14
|
TEST_MODE = False
|
|
@@ -23,14 +18,6 @@ class AccessError(Exception):
|
|
|
23
18
|
pass
|
|
24
19
|
|
|
25
20
|
|
|
26
|
-
def _get_closest_gap_for_energy(
|
|
27
|
-
dcm_energy_ev: float, energy_to_distance_table: ndarray
|
|
28
|
-
) -> float:
|
|
29
|
-
table = energy_to_distance_table.transpose()
|
|
30
|
-
idx = argmin(np.abs(table[0] - dcm_energy_ev))
|
|
31
|
-
return table[1][idx]
|
|
32
|
-
|
|
33
|
-
|
|
34
21
|
class UndulatorDCM(StandardReadable, Movable):
|
|
35
22
|
"""
|
|
36
23
|
Composite device to handle changing beamline energies, wraps the Undulator and the
|
|
@@ -48,7 +35,6 @@ class UndulatorDCM(StandardReadable, Movable):
|
|
|
48
35
|
self,
|
|
49
36
|
undulator: Undulator,
|
|
50
37
|
dcm: DCM,
|
|
51
|
-
id_gap_lookup_table_path: str,
|
|
52
38
|
daq_configuration_path: str,
|
|
53
39
|
prefix: str = "",
|
|
54
40
|
name: str = "",
|
|
@@ -61,11 +47,10 @@ class UndulatorDCM(StandardReadable, Movable):
|
|
|
61
47
|
self.dcm = dcm
|
|
62
48
|
|
|
63
49
|
# These attributes are just used by hyperion for lookup purposes
|
|
64
|
-
self.
|
|
65
|
-
self.dcm_pitch_converter_lookup_table_path = (
|
|
50
|
+
self.pitch_energy_table_path = (
|
|
66
51
|
daq_configuration_path + "/lookup/BeamLineEnergy_DCM_Pitch_converter.txt"
|
|
67
52
|
)
|
|
68
|
-
self.
|
|
53
|
+
self.roll_energy_table_path = (
|
|
69
54
|
daq_configuration_path + "/lookup/BeamLineEnergy_DCM_Roll_converter.txt"
|
|
70
55
|
)
|
|
71
56
|
# I03 configures the DCM Perp as a side effect of applying this fixed value to the DCM Offset after an energy change
|
|
@@ -78,7 +63,7 @@ class UndulatorDCM(StandardReadable, Movable):
|
|
|
78
63
|
async def set(self, value: float):
|
|
79
64
|
await asyncio.gather(
|
|
80
65
|
self._set_dcm_energy(value),
|
|
81
|
-
self.
|
|
66
|
+
self.undulator.set(value),
|
|
82
67
|
)
|
|
83
68
|
|
|
84
69
|
async def _set_dcm_energy(self, energy_kev: float) -> None:
|
|
@@ -90,42 +75,3 @@ class UndulatorDCM(StandardReadable, Movable):
|
|
|
90
75
|
energy_kev,
|
|
91
76
|
timeout=ENERGY_TIMEOUT_S,
|
|
92
77
|
)
|
|
93
|
-
|
|
94
|
-
async def _set_undulator_gap_if_required(self, energy_kev: float) -> None:
|
|
95
|
-
LOGGER.info(f"Setting DCM energy to {energy_kev:.2f} kev")
|
|
96
|
-
gap_to_match_dcm_energy = await self._gap_to_match_dcm_energy(energy_kev)
|
|
97
|
-
|
|
98
|
-
# Check if undulator gap is close enough to the value from the DCM
|
|
99
|
-
current_gap = await self.undulator.current_gap.get_value()
|
|
100
|
-
tolerance = await self.undulator.gap_discrepancy_tolerance_mm.get_value()
|
|
101
|
-
if abs(gap_to_match_dcm_energy - current_gap) > tolerance:
|
|
102
|
-
LOGGER.info(
|
|
103
|
-
f"Undulator gap mismatch. {abs(gap_to_match_dcm_energy-current_gap):.3f}mm is outside tolerance.\
|
|
104
|
-
Moving gap to nominal value, {gap_to_match_dcm_energy:.3f}mm"
|
|
105
|
-
)
|
|
106
|
-
if not TEST_MODE:
|
|
107
|
-
# Only move if the gap is sufficiently different to the value from the
|
|
108
|
-
# DCM lookup table AND we're not in TEST_MODE
|
|
109
|
-
await self.undulator.gap_motor.set(
|
|
110
|
-
gap_to_match_dcm_energy,
|
|
111
|
-
timeout=STATUS_TIMEOUT_S,
|
|
112
|
-
)
|
|
113
|
-
else:
|
|
114
|
-
LOGGER.debug("In test mode, not moving ID gap")
|
|
115
|
-
else:
|
|
116
|
-
LOGGER.debug(
|
|
117
|
-
"Gap is already in the correct place for the new energy value "
|
|
118
|
-
f"{energy_kev}, no need to ask it to move"
|
|
119
|
-
)
|
|
120
|
-
|
|
121
|
-
async def _gap_to_match_dcm_energy(self, energy_kev: float) -> float:
|
|
122
|
-
# Get 2d np.array converting energies to undulator gap distance, from lookup table
|
|
123
|
-
energy_to_distance_table = await energy_distance_table(
|
|
124
|
-
self.id_gap_lookup_table_path
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
# Use the lookup table to get the undulator gap associated with this dcm energy
|
|
128
|
-
return _get_closest_gap_for_energy(
|
|
129
|
-
energy_kev * 1000,
|
|
130
|
-
energy_to_distance_table,
|
|
131
|
-
)
|
dodal/devices/webcam.py
CHANGED
|
@@ -1,12 +1,24 @@
|
|
|
1
|
+
from collections.abc import ByteString
|
|
2
|
+
from io import BytesIO
|
|
1
3
|
from pathlib import Path
|
|
2
4
|
|
|
3
5
|
import aiofiles
|
|
4
6
|
from aiohttp import ClientSession
|
|
5
7
|
from bluesky.protocols import Triggerable
|
|
6
8
|
from ophyd_async.core import AsyncStatus, HintedSignal, StandardReadable, soft_signal_rw
|
|
9
|
+
from PIL import Image
|
|
7
10
|
|
|
8
11
|
from dodal.log import LOGGER
|
|
9
12
|
|
|
13
|
+
PLACEHOLDER_IMAGE_SIZE = (1024, 768)
|
|
14
|
+
IMAGE_FORMAT = "png"
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def create_placeholder_image() -> ByteString:
|
|
18
|
+
image = Image.new("RGB", PLACEHOLDER_IMAGE_SIZE)
|
|
19
|
+
image.save(buffer := BytesIO(), format=IMAGE_FORMAT)
|
|
20
|
+
return buffer.getbuffer()
|
|
21
|
+
|
|
10
22
|
|
|
11
23
|
class Webcam(StandardReadable, Triggerable):
|
|
12
24
|
def __init__(self, name, prefix, url):
|
|
@@ -18,19 +30,33 @@ class Webcam(StandardReadable, Triggerable):
|
|
|
18
30
|
self.add_readables([self.last_saved_path], wrapper=HintedSignal)
|
|
19
31
|
super().__init__(name=name)
|
|
20
32
|
|
|
21
|
-
async def _write_image(self, file_path: str):
|
|
33
|
+
async def _write_image(self, file_path: str, image: ByteString):
|
|
34
|
+
async with aiofiles.open(file_path, "wb") as file:
|
|
35
|
+
await file.write(image)
|
|
36
|
+
|
|
37
|
+
async def _get_and_write_image(self, file_path: str):
|
|
22
38
|
async with ClientSession() as session:
|
|
23
39
|
async with session.get(self.url) as response:
|
|
24
|
-
response.
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
40
|
+
if not response.ok:
|
|
41
|
+
LOGGER.warning(
|
|
42
|
+
f"Webcam responded with {response.status}: {response.reason}. Attempting to read anyway."
|
|
43
|
+
)
|
|
44
|
+
try:
|
|
45
|
+
data = await response.read()
|
|
46
|
+
LOGGER.info(f"Saving webcam image from {self.url} to {file_path}")
|
|
47
|
+
except Exception as e:
|
|
48
|
+
LOGGER.warning(
|
|
49
|
+
f"Failed to read data from {self.url} ({e}). Using placeholder image."
|
|
50
|
+
)
|
|
51
|
+
data = create_placeholder_image()
|
|
52
|
+
|
|
53
|
+
await self._write_image(file_path, data)
|
|
28
54
|
|
|
29
55
|
@AsyncStatus.wrap
|
|
30
56
|
async def trigger(self) -> None:
|
|
31
57
|
filename = await self.filename.get_value()
|
|
32
58
|
directory = await self.directory.get_value()
|
|
33
59
|
|
|
34
|
-
file_path = Path(f"{directory}/{filename}.
|
|
35
|
-
await self.
|
|
60
|
+
file_path = Path(f"{directory}/{filename}.{IMAGE_FORMAT}").as_posix()
|
|
61
|
+
await self._get_and_write_image(file_path)
|
|
36
62
|
await self.last_saved_path.set(file_path)
|
|
@@ -39,7 +39,12 @@ class ZocaloStartInfo:
|
|
|
39
39
|
|
|
40
40
|
|
|
41
41
|
def _get_zocalo_headers() -> tuple[str, str]:
|
|
42
|
-
user = os.environ.get("ZOCALO_GO_USER"
|
|
42
|
+
user = os.environ.get("ZOCALO_GO_USER")
|
|
43
|
+
|
|
44
|
+
# cannot default as getuser() will throw when called from inside a container
|
|
45
|
+
if not user:
|
|
46
|
+
user = getpass.getuser()
|
|
47
|
+
|
|
43
48
|
hostname = os.environ.get("ZOCALO_GO_HOSTNAME", socket.gethostname())
|
|
44
49
|
return user, hostname
|
|
45
50
|
|