dls-dodal 1.33.0__py3-none-any.whl → 1.34.1__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.33.0.dist-info → dls_dodal-1.34.1.dist-info}/METADATA +3 -3
- {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/RECORD +50 -42
- {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/WHEEL +1 -1
- dodal/__init__.py +8 -0
- dodal/_version.py +2 -2
- dodal/beamline_specific_utils/i03.py +6 -2
- dodal/beamlines/__init__.py +2 -3
- dodal/beamlines/i03.py +41 -9
- dodal/beamlines/i04.py +26 -4
- dodal/beamlines/i10.py +257 -0
- dodal/beamlines/i22.py +1 -2
- dodal/beamlines/i24.py +7 -7
- dodal/beamlines/p38.py +1 -2
- dodal/common/types.py +2 -7
- dodal/devices/apple2_undulator.py +602 -0
- dodal/devices/areadetector/plugins/CAM.py +31 -0
- dodal/devices/areadetector/plugins/MJPG.py +51 -106
- dodal/devices/backlight.py +7 -6
- dodal/devices/diamond_filter.py +47 -0
- dodal/devices/eiger.py +6 -2
- dodal/devices/eiger_odin.py +48 -39
- dodal/devices/focusing_mirror.py +14 -8
- dodal/devices/i10/i10_apple2.py +398 -0
- dodal/devices/i10/i10_setting_data.py +7 -0
- dodal/devices/i22/dcm.py +7 -8
- dodal/devices/i24/dual_backlight.py +5 -5
- dodal/devices/oav/oav_calculations.py +22 -0
- dodal/devices/oav/oav_detector.py +118 -83
- dodal/devices/oav/oav_parameters.py +50 -104
- dodal/devices/oav/oav_to_redis_forwarder.py +75 -34
- dodal/devices/oav/{grid_overlay.py → snapshots/grid_overlay.py} +0 -43
- dodal/devices/oav/snapshots/snapshot_with_beam_centre.py +64 -0
- dodal/devices/oav/snapshots/snapshot_with_grid.py +57 -0
- dodal/devices/oav/utils.py +26 -25
- dodal/devices/pgm.py +41 -0
- dodal/devices/qbpm.py +18 -0
- dodal/devices/robot.py +2 -2
- dodal/devices/smargon.py +2 -2
- dodal/devices/tetramm.py +2 -2
- dodal/devices/undulator.py +2 -1
- dodal/devices/util/adjuster_plans.py +1 -1
- dodal/devices/util/lookup_tables.py +4 -5
- dodal/devices/zebra.py +5 -2
- dodal/devices/zocalo/zocalo_results.py +13 -10
- dodal/plans/data_session_metadata.py +2 -2
- dodal/plans/motor_util_plans.py +11 -9
- dodal/utils.py +7 -0
- dodal/beamlines/i04_1.py +0 -140
- dodal/devices/oav/oav_errors.py +0 -35
- {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/LICENSE +0 -0
- {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/entry_points.txt +0 -0
- {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/top_level.txt +0 -0
|
@@ -1,26 +1,35 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
from
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
1
|
+
from enum import IntEnum
|
|
2
|
+
|
|
3
|
+
from ophyd_async.core import DEFAULT_TIMEOUT, AsyncStatus, StandardReadable
|
|
4
|
+
from ophyd_async.epics.signal import epics_signal_rw
|
|
5
|
+
|
|
6
|
+
from dodal.common.signal_utils import create_hardware_backed_soft_signal
|
|
7
|
+
from dodal.devices.areadetector.plugins.CAM import Cam
|
|
8
|
+
from dodal.devices.oav.oav_parameters import DEFAULT_OAV_WINDOW, OAVConfig
|
|
9
|
+
from dodal.devices.oav.snapshots.snapshot_with_beam_centre import SnapshotWithBeamCentre
|
|
10
|
+
from dodal.devices.oav.snapshots.snapshot_with_grid import SnapshotWithGrid
|
|
11
|
+
from dodal.log import LOGGER
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ZoomLevelNotFoundError(Exception):
|
|
15
|
+
def __init__(self, errmsg):
|
|
16
|
+
LOGGER.error(errmsg)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class Coords(IntEnum):
|
|
20
|
+
X = 0
|
|
21
|
+
Y = 1
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Workaround to deal with the fact that beamlines may have slightly different string
|
|
25
|
+
# descriptions of the zoom level"
|
|
26
|
+
def _get_correct_zoom_string(zoom: str) -> str:
|
|
27
|
+
if zoom.endswith("x"):
|
|
28
|
+
zoom = zoom.strip("x")
|
|
29
|
+
return zoom
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class ZoomController(StandardReadable):
|
|
24
33
|
"""
|
|
25
34
|
Device to control the zoom level. This should be set like
|
|
26
35
|
o = OAV(name="oav")
|
|
@@ -30,63 +39,89 @@ class ZoomController(Device):
|
|
|
30
39
|
you should wait on any zoom changs to finish before changing the OAV wiring.
|
|
31
40
|
"""
|
|
32
41
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
42
|
+
def __init__(self, prefix: str, name: str = "") -> None:
|
|
43
|
+
super().__init__(name=name)
|
|
44
|
+
self.percentage = epics_signal_rw(float, f"{prefix}ZOOMPOSCMD")
|
|
45
|
+
|
|
46
|
+
# Level is the string description of the zoom level e.g. "1.0x" or "1.0"
|
|
47
|
+
self.level = epics_signal_rw(str, f"{prefix}MP:SELECT")
|
|
48
|
+
|
|
49
|
+
async def _get_allowed_zoom_levels(self) -> list:
|
|
50
|
+
zoom_levels = await self.level.describe()
|
|
51
|
+
return zoom_levels["level"]["choices"] # type: ignore
|
|
52
|
+
|
|
53
|
+
@AsyncStatus.wrap
|
|
54
|
+
async def set(self, level_to_set: str):
|
|
55
|
+
allowed_zoom_levels = await self._get_allowed_zoom_levels()
|
|
56
|
+
if level_to_set not in allowed_zoom_levels:
|
|
57
|
+
raise ZoomLevelNotFoundError(
|
|
58
|
+
f"{level_to_set} not found, expected one of {allowed_zoom_levels}"
|
|
59
|
+
)
|
|
60
|
+
await self.level.set(level_to_set, wait=True)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
class OAV(StandardReadable):
|
|
64
|
+
def __init__(self, prefix: str, config: OAVConfig, name: str = ""):
|
|
65
|
+
self.oav_config = config
|
|
66
|
+
self._prefix = prefix
|
|
67
|
+
self._name = name
|
|
68
|
+
_bl_prefix = prefix.split("-")[0]
|
|
69
|
+
self.zoom_controller = ZoomController(f"{_bl_prefix}-EA-OAV-01:FZOOM:", name)
|
|
70
|
+
|
|
71
|
+
self.cam = Cam(f"{prefix}CAM:", name=name)
|
|
72
|
+
|
|
73
|
+
with self.add_children_as_readables():
|
|
74
|
+
self.grid_snapshot = SnapshotWithGrid(f"{prefix}MJPG:", name)
|
|
75
|
+
self.microns_per_pixel_x = create_hardware_backed_soft_signal(
|
|
76
|
+
float,
|
|
77
|
+
lambda: self._get_microns_per_pixel(Coords.X),
|
|
78
|
+
)
|
|
79
|
+
self.microns_per_pixel_y = create_hardware_backed_soft_signal(
|
|
80
|
+
float,
|
|
81
|
+
lambda: self._get_microns_per_pixel(Coords.Y),
|
|
82
|
+
)
|
|
83
|
+
self.beam_centre_i = create_hardware_backed_soft_signal(
|
|
84
|
+
int, lambda: self._get_beam_position(Coords.X)
|
|
85
|
+
)
|
|
86
|
+
self.beam_centre_j = create_hardware_backed_soft_signal(
|
|
87
|
+
int, lambda: self._get_beam_position(Coords.Y)
|
|
88
|
+
)
|
|
89
|
+
self.snapshot = SnapshotWithBeamCentre(
|
|
90
|
+
f"{self._prefix}MJPG:",
|
|
91
|
+
self.beam_centre_i,
|
|
92
|
+
self.beam_centre_j,
|
|
93
|
+
self._name,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
self.sizes = [self.grid_snapshot.x_size, self.grid_snapshot.y_size]
|
|
97
|
+
|
|
98
|
+
super().__init__(name)
|
|
99
|
+
|
|
100
|
+
async def _read_current_zoom(self) -> str:
|
|
101
|
+
_zoom = await self.zoom_controller.level.get_value()
|
|
102
|
+
return _get_correct_zoom_string(_zoom)
|
|
103
|
+
|
|
104
|
+
async def _get_microns_per_pixel(self, coord: int) -> float:
|
|
105
|
+
"""Extracts the microns per x pixel and y pixel for a given zoom level."""
|
|
106
|
+
_zoom = await self._read_current_zoom()
|
|
107
|
+
value = self.parameters[_zoom].microns_per_pixel[coord]
|
|
108
|
+
size = await self.sizes[coord].get_value()
|
|
109
|
+
return value * DEFAULT_OAV_WINDOW[coord] / size
|
|
110
|
+
|
|
111
|
+
async def _get_beam_position(self, coord: int) -> int:
|
|
112
|
+
"""Extracts the beam location in pixels `xCentre` `yCentre`, for a requested \
|
|
113
|
+
zoom level. """
|
|
114
|
+
_zoom = await self._read_current_zoom()
|
|
115
|
+
value = self.parameters[_zoom].crosshair[coord]
|
|
116
|
+
size = await self.sizes[coord].get_value()
|
|
117
|
+
return int(value * size / DEFAULT_OAV_WINDOW[coord])
|
|
118
|
+
|
|
119
|
+
async def connect(
|
|
120
|
+
self,
|
|
121
|
+
mock: bool = False,
|
|
122
|
+
timeout: float = DEFAULT_TIMEOUT,
|
|
123
|
+
force_reconnect: bool = False,
|
|
124
|
+
):
|
|
125
|
+
self.parameters = self.oav_config.get_parameters()
|
|
126
|
+
|
|
127
|
+
return await super().connect(mock, timeout, force_reconnect)
|
|
@@ -1,15 +1,10 @@
|
|
|
1
1
|
import json
|
|
2
2
|
import xml.etree.ElementTree as et
|
|
3
3
|
from collections import ChainMap
|
|
4
|
+
from dataclasses import dataclass
|
|
4
5
|
from typing import Any
|
|
5
6
|
from xml.etree.ElementTree import Element
|
|
6
7
|
|
|
7
|
-
from dodal.devices.oav.oav_errors import (
|
|
8
|
-
OAVError_BeamPositionNotFound,
|
|
9
|
-
OAVError_ZoomLevelNotFound,
|
|
10
|
-
)
|
|
11
|
-
from dodal.log import LOGGER
|
|
12
|
-
|
|
13
8
|
# GDA currently assumes this aspect ratio for the OAV window size.
|
|
14
9
|
# For some beamline this doesn't affect anything as the actual OAV aspect ratio
|
|
15
10
|
# matches. Others need to take it into account to rescale the values stored in
|
|
@@ -109,106 +104,57 @@ class OAVParameters:
|
|
|
109
104
|
return self.max_tip_distance / micronsPerPixel
|
|
110
105
|
|
|
111
106
|
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
107
|
+
@dataclass
|
|
108
|
+
class ZoomParams:
|
|
109
|
+
microns_per_pixel: tuple[float, float]
|
|
110
|
+
crosshair: tuple[int, int]
|
|
116
111
|
|
|
117
|
-
def __init__(
|
|
118
|
-
self,
|
|
119
|
-
zoom_params_file,
|
|
120
|
-
display_config,
|
|
121
|
-
):
|
|
122
|
-
self.zoom_params_file: str = zoom_params_file
|
|
123
|
-
self.display_config: str = display_config
|
|
124
|
-
|
|
125
|
-
def update_on_zoom(self, value, xsize, ysize, *args, **kwargs):
|
|
126
|
-
xsize, ysize = int(xsize), int(ysize)
|
|
127
|
-
if isinstance(value, str) and value.endswith("x"):
|
|
128
|
-
value = value.strip("x")
|
|
129
|
-
zoom = float(value)
|
|
130
|
-
self.load_microns_per_pixel(zoom, xsize, ysize)
|
|
131
|
-
self.beam_centre_i, self.beam_centre_j = self.get_beam_position_from_zoom(
|
|
132
|
-
zoom, xsize, ysize
|
|
133
|
-
)
|
|
134
|
-
|
|
135
|
-
def load_microns_per_pixel(self, zoom: float, xsize: int, ysize: int) -> None:
|
|
136
|
-
"""
|
|
137
|
-
Loads the microns per x pixel and y pixel for a given zoom level. These are
|
|
138
|
-
currently generated by GDA, though hyperion could generate them in future.
|
|
139
|
-
"""
|
|
140
|
-
tree = et.parse(self.zoom_params_file)
|
|
141
|
-
self.micronsPerXPixel = self.micronsPerYPixel = None
|
|
142
|
-
root = tree.getroot()
|
|
143
|
-
levels = root.findall(".//zoomLevel")
|
|
144
|
-
for node in levels:
|
|
145
|
-
if _get_element_as_float(node, "level") == zoom:
|
|
146
|
-
self.micronsPerXPixel = (
|
|
147
|
-
_get_element_as_float(node, "micronsPerXPixel")
|
|
148
|
-
* DEFAULT_OAV_WINDOW[0]
|
|
149
|
-
/ xsize
|
|
150
|
-
)
|
|
151
|
-
self.micronsPerYPixel = (
|
|
152
|
-
_get_element_as_float(node, "micronsPerYPixel")
|
|
153
|
-
* DEFAULT_OAV_WINDOW[1]
|
|
154
|
-
/ ysize
|
|
155
|
-
)
|
|
156
|
-
if self.micronsPerXPixel is None or self.micronsPerYPixel is None:
|
|
157
|
-
raise OAVError_ZoomLevelNotFound(
|
|
158
|
-
f"""
|
|
159
|
-
Could not find the micronsPer[X,Y]Pixel parameters in
|
|
160
|
-
{self.zoom_params_file} for zoom level {zoom}.
|
|
161
|
-
"""
|
|
162
|
-
)
|
|
163
112
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
Extracts the beam location in pixels `xCentre` `yCentre`, for a requested zoom \
|
|
169
|
-
level. The beam location is manually inputted by the beamline operator on GDA \
|
|
170
|
-
by clicking where on screen a scintillator lights up, and stored in the \
|
|
171
|
-
display.configuration file.
|
|
172
|
-
"""
|
|
173
|
-
crosshair_x_line = None
|
|
174
|
-
crosshair_y_line = None
|
|
175
|
-
with open(self.display_config) as f:
|
|
176
|
-
file_lines = f.readlines()
|
|
177
|
-
for i in range(len(file_lines)):
|
|
178
|
-
if file_lines[i].startswith("zoomLevel = " + str(zoom)):
|
|
179
|
-
crosshair_x_line = file_lines[i + 1]
|
|
180
|
-
crosshair_y_line = file_lines[i + 2]
|
|
181
|
-
break
|
|
182
|
-
|
|
183
|
-
if crosshair_x_line is None or crosshair_y_line is None:
|
|
184
|
-
raise OAVError_BeamPositionNotFound(
|
|
185
|
-
f"Could not extract beam position at zoom level {zoom}"
|
|
186
|
-
)
|
|
113
|
+
class OAVConfig:
|
|
114
|
+
""" Read the OAV config files and return a dictionary of {'zoom_level': ZoomParams}\
|
|
115
|
+
with information about microns per pixels and crosshairs.
|
|
116
|
+
"""
|
|
187
117
|
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
)
|
|
191
|
-
beam_centre_j = (
|
|
192
|
-
int(crosshair_y_line.split(" = ")[1]) * ysize / DEFAULT_OAV_WINDOW[1]
|
|
193
|
-
)
|
|
194
|
-
LOGGER.info(f"Beam centre: {beam_centre_i, beam_centre_j}")
|
|
195
|
-
return int(beam_centre_i), int(beam_centre_j)
|
|
118
|
+
def __init__(self, zoom_params_file: str, display_config_file: str):
|
|
119
|
+
self.zoom_params = self._get_zoom_params(zoom_params_file)
|
|
120
|
+
self.display_config = self._get_display_config(display_config_file)
|
|
196
121
|
|
|
197
|
-
def
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
Calculates the distance between the beam centre and the given (horizontal, vertical).
|
|
202
|
-
|
|
203
|
-
Args:
|
|
204
|
-
horizontal_pixels (int): The x (camera coordinates) value in pixels.
|
|
205
|
-
vertical_pixels (int): The y (camera coordinates) value in pixels.
|
|
206
|
-
Returns:
|
|
207
|
-
The distance between the beam centre and the (horizontal, vertical) point in pixels as a tuple
|
|
208
|
-
(horizontal_distance, vertical_distance).
|
|
209
|
-
"""
|
|
122
|
+
def _get_display_config(self, display_config_file: str):
|
|
123
|
+
with open(display_config_file) as f:
|
|
124
|
+
file_lines = f.readlines()
|
|
125
|
+
return file_lines
|
|
210
126
|
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
)
|
|
127
|
+
def _get_zoom_params(self, zoom_params_file: str):
|
|
128
|
+
tree = et.parse(zoom_params_file)
|
|
129
|
+
root = tree.getroot()
|
|
130
|
+
return root.findall(".//zoomLevel")
|
|
131
|
+
|
|
132
|
+
def _read_zoom_params(self) -> dict:
|
|
133
|
+
um_per_pix = {}
|
|
134
|
+
for node in self.zoom_params:
|
|
135
|
+
zoom = str(_get_element_as_float(node, "level"))
|
|
136
|
+
um_pix_x = _get_element_as_float(node, "micronsPerXPixel")
|
|
137
|
+
um_pix_y = _get_element_as_float(node, "micronsPerYPixel")
|
|
138
|
+
um_per_pix[zoom] = (um_pix_x, um_pix_y)
|
|
139
|
+
return um_per_pix
|
|
140
|
+
|
|
141
|
+
def _read_display_config(self) -> dict:
|
|
142
|
+
crosshairs = {}
|
|
143
|
+
for i in range(len(self.display_config)):
|
|
144
|
+
if self.display_config[i].startswith("zoomLevel"):
|
|
145
|
+
zoom = self.display_config[i].split(" = ")[1].strip()
|
|
146
|
+
x = int(self.display_config[i + 1].split(" = ")[1])
|
|
147
|
+
y = int(self.display_config[i + 2].split(" = ")[1])
|
|
148
|
+
crosshairs[zoom] = (x, y)
|
|
149
|
+
return crosshairs
|
|
150
|
+
|
|
151
|
+
def get_parameters(self) -> dict[str, ZoomParams]:
|
|
152
|
+
config = {}
|
|
153
|
+
um_xy = self._read_zoom_params()
|
|
154
|
+
bc_xy = self._read_display_config()
|
|
155
|
+
for zoom_key in list(bc_xy.keys()):
|
|
156
|
+
config[zoom_key] = ZoomParams(
|
|
157
|
+
microns_per_pixel=um_xy[zoom_key],
|
|
158
|
+
crosshair=bc_xy[zoom_key],
|
|
159
|
+
)
|
|
160
|
+
return config
|
|
@@ -1,21 +1,20 @@
|
|
|
1
1
|
import asyncio
|
|
2
|
-
import io
|
|
3
|
-
import pickle
|
|
4
|
-
import uuid
|
|
5
2
|
from collections.abc import Awaitable, Callable
|
|
6
3
|
from datetime import timedelta
|
|
4
|
+
from enum import Enum
|
|
5
|
+
from uuid import uuid4
|
|
7
6
|
|
|
8
|
-
import numpy as np
|
|
9
7
|
from aiohttp import ClientResponse, ClientSession
|
|
10
8
|
from bluesky.protocols import Flyable, Stoppable
|
|
11
9
|
from ophyd_async.core import (
|
|
12
10
|
AsyncStatus,
|
|
11
|
+
DeviceVector,
|
|
13
12
|
StandardReadable,
|
|
13
|
+
observe_value,
|
|
14
14
|
soft_signal_r_and_setter,
|
|
15
15
|
soft_signal_rw,
|
|
16
16
|
)
|
|
17
17
|
from ophyd_async.epics.signal import epics_signal_r
|
|
18
|
-
from PIL import Image
|
|
19
18
|
from redis.asyncio import StrictRedis
|
|
20
19
|
|
|
21
20
|
from dodal.log import LOGGER
|
|
@@ -30,6 +29,21 @@ async def get_next_jpeg(response: ClientResponse) -> bytes:
|
|
|
30
29
|
return line + await response.content.readuntil(JPEG_STOP_BYTE)
|
|
31
30
|
|
|
32
31
|
|
|
32
|
+
class Source(Enum):
|
|
33
|
+
FULL_SCREEN = 0
|
|
34
|
+
ROI = 1
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class OAVSource(StandardReadable):
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
prefix: str,
|
|
41
|
+
oav_name: str,
|
|
42
|
+
):
|
|
43
|
+
self.url = epics_signal_r(str, f"{prefix}MJPG_URL_RBV")
|
|
44
|
+
self.oav_name = oav_name
|
|
45
|
+
|
|
46
|
+
|
|
33
47
|
class OAVToRedisForwarder(StandardReadable, Flyable, Stoppable):
|
|
34
48
|
"""Forwards OAV image data to redis. To use call:
|
|
35
49
|
|
|
@@ -41,6 +55,9 @@ class OAVToRedisForwarder(StandardReadable, Flyable, Stoppable):
|
|
|
41
55
|
|
|
42
56
|
DATA_EXPIRY_DAYS = 7
|
|
43
57
|
|
|
58
|
+
# This timeout is the maximum time that the forwarder can be streaming for
|
|
59
|
+
TIMEOUT = 30
|
|
60
|
+
|
|
44
61
|
def __init__(
|
|
45
62
|
self,
|
|
46
63
|
prefix: str,
|
|
@@ -59,59 +76,80 @@ class OAVToRedisForwarder(StandardReadable, Flyable, Stoppable):
|
|
|
59
76
|
redis_db: int which redis database to connect to, defaults to 0
|
|
60
77
|
name: str the name of this device
|
|
61
78
|
"""
|
|
62
|
-
self.
|
|
79
|
+
self.counter = epics_signal_r(int, f"{prefix}CAM:ArrayCounter_RBV")
|
|
63
80
|
|
|
64
|
-
|
|
65
|
-
|
|
81
|
+
self._sources = DeviceVector(
|
|
82
|
+
{
|
|
83
|
+
Source.ROI.value: OAVSource(f"{prefix}MJPG:", "roi"),
|
|
84
|
+
Source.FULL_SCREEN.value: OAVSource(f"{prefix}XTAL:", "fullscreen"),
|
|
85
|
+
}
|
|
86
|
+
)
|
|
87
|
+
self.selected_source = soft_signal_rw(Source)
|
|
66
88
|
|
|
67
89
|
self.forwarding_task = None
|
|
68
90
|
self.redis_client = StrictRedis(
|
|
69
91
|
host=redis_host, password=redis_password, db=redis_db
|
|
70
92
|
)
|
|
71
93
|
|
|
72
|
-
self._stop_flag =
|
|
94
|
+
self._stop_flag = asyncio.Event()
|
|
73
95
|
|
|
74
96
|
self.sample_id = soft_signal_rw(int, initial_value=0)
|
|
75
97
|
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
98
|
+
with self.add_children_as_readables():
|
|
99
|
+
# The uuid that images are being saved under, this should be monitored for
|
|
100
|
+
# callbacks to correlate the data
|
|
101
|
+
self.uuid, self.uuid_setter = soft_signal_r_and_setter(str)
|
|
79
102
|
|
|
80
103
|
super().__init__(name=name)
|
|
81
104
|
|
|
82
|
-
async def _get_frame_and_put_to_redis(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
"""
|
|
105
|
+
async def _get_frame_and_put_to_redis(
|
|
106
|
+
self, redis_uuid: str, response: ClientResponse
|
|
107
|
+
):
|
|
108
|
+
"""Stores the raw bytes of the jpeg image in redis. Murko ultimately wants a
|
|
109
|
+
pickled numpy array of pixel values but raw byes are more space efficient. There
|
|
110
|
+
may be better ways of doing this, see https://github.com/DiamondLightSource/mx-bluesky/issues/592"""
|
|
86
111
|
jpeg_bytes = await get_next_jpeg(response)
|
|
87
|
-
self.uuid_setter(
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
await self.redis_client.
|
|
92
|
-
await self.redis_client.expire(sample_id, timedelta(days=self.DATA_EXPIRY_DAYS))
|
|
93
|
-
LOGGER.debug(f"Sent frame to redis key {sample_id} with uuid {image_uuid}")
|
|
112
|
+
self.uuid_setter(redis_uuid)
|
|
113
|
+
sample_id = await self.sample_id.get_value()
|
|
114
|
+
redis_key = f"murko:{sample_id}:raw"
|
|
115
|
+
await self.redis_client.hset(redis_key, redis_uuid, jpeg_bytes) # type: ignore
|
|
116
|
+
await self.redis_client.expire(redis_key, timedelta(days=self.DATA_EXPIRY_DAYS))
|
|
94
117
|
|
|
95
118
|
async def _open_connection_and_do_function(
|
|
96
|
-
self, function_to_do: Callable[[ClientResponse,
|
|
119
|
+
self, function_to_do: Callable[[ClientResponse, OAVSource], Awaitable]
|
|
97
120
|
):
|
|
98
|
-
|
|
121
|
+
source_name = await self.selected_source.get_value()
|
|
122
|
+
LOGGER.info(
|
|
123
|
+
f"Forwarding data from sample {await self.sample_id.get_value()} and OAV {source_name}"
|
|
124
|
+
)
|
|
125
|
+
source = self._sources[source_name.value]
|
|
126
|
+
stream_url = await source.url.get_value()
|
|
99
127
|
async with ClientSession() as session:
|
|
100
128
|
async with session.get(stream_url) as response:
|
|
101
|
-
await function_to_do(response,
|
|
129
|
+
await function_to_do(response, source)
|
|
130
|
+
|
|
131
|
+
async def _stream_to_redis(self, response: ClientResponse, source: OAVSource):
|
|
132
|
+
"""Uses the update of the frame counter as a trigger to pull an image off the OAV
|
|
133
|
+
and into redis.
|
|
102
134
|
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
135
|
+
The frame counter is continually increasing on the timescales we store data and
|
|
136
|
+
so can be used as a uuid. If the OAV is updating too quickly we may drop frames
|
|
137
|
+
but in this case a best effort on getting as many frames as possible is sufficient.
|
|
138
|
+
"""
|
|
139
|
+
done_status = AsyncStatus(
|
|
140
|
+
asyncio.wait_for(self._stop_flag.wait(), timeout=self.TIMEOUT)
|
|
141
|
+
)
|
|
142
|
+
async for frame_count in observe_value(self.counter, done_status=done_status):
|
|
143
|
+
redis_uuid = f"{source.oav_name}-{frame_count}-{uuid4()}"
|
|
144
|
+
await self._get_frame_and_put_to_redis(redis_uuid, response)
|
|
107
145
|
|
|
108
|
-
async def _confirm_mjpg_stream(self, response,
|
|
146
|
+
async def _confirm_mjpg_stream(self, response: ClientResponse, source: OAVSource):
|
|
109
147
|
if response.content_type != "multipart/x-mixed-replace":
|
|
110
|
-
raise ValueError(f"{
|
|
148
|
+
raise ValueError(f"{await source.url.get_value()} is not an MJPG stream")
|
|
111
149
|
|
|
112
150
|
@AsyncStatus.wrap
|
|
113
151
|
async def kickoff(self):
|
|
114
|
-
self._stop_flag
|
|
152
|
+
self._stop_flag.clear()
|
|
115
153
|
await self._open_connection_and_do_function(self._confirm_mjpg_stream)
|
|
116
154
|
self.forwarding_task = asyncio.create_task(
|
|
117
155
|
self._open_connection_and_do_function(self._stream_to_redis)
|
|
@@ -125,5 +163,8 @@ class OAVToRedisForwarder(StandardReadable, Flyable, Stoppable):
|
|
|
125
163
|
@AsyncStatus.wrap
|
|
126
164
|
async def stop(self, success=True):
|
|
127
165
|
if self.forwarding_task:
|
|
128
|
-
|
|
166
|
+
LOGGER.info(
|
|
167
|
+
f"Stopping forwarding for {await self.selected_source.get_value()}"
|
|
168
|
+
)
|
|
169
|
+
self._stop_flag.set()
|
|
129
170
|
await self.forwarding_task
|
|
@@ -1,14 +1,8 @@
|
|
|
1
|
-
# type: ignore # OAV will soon be ophyd-async, see https://github.com/DiamondLightSource/dodal/issues/716
|
|
2
1
|
from enum import Enum
|
|
3
2
|
from functools import partial
|
|
4
|
-
from os.path import join as path_join
|
|
5
3
|
|
|
6
|
-
from ophyd import Component, Signal
|
|
7
4
|
from PIL import Image, ImageDraw
|
|
8
5
|
|
|
9
|
-
from dodal.devices.areadetector.plugins.MJPG import MJPG
|
|
10
|
-
from dodal.log import LOGGER
|
|
11
|
-
|
|
12
6
|
|
|
13
7
|
class Orientation(Enum):
|
|
14
8
|
horizontal = 0
|
|
@@ -122,40 +116,3 @@ def add_grid_overlay_to_image(
|
|
|
122
116
|
spacing=box_width,
|
|
123
117
|
num_lines=num_boxes_y - 1,
|
|
124
118
|
)
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
class SnapshotWithGrid(MJPG):
|
|
128
|
-
top_left_x = Component(Signal)
|
|
129
|
-
top_left_y = Component(Signal)
|
|
130
|
-
box_width = Component(Signal)
|
|
131
|
-
num_boxes_x = Component(Signal)
|
|
132
|
-
num_boxes_y = Component(Signal)
|
|
133
|
-
|
|
134
|
-
last_path_outer = Component(Signal)
|
|
135
|
-
last_path_full_overlay = Component(Signal)
|
|
136
|
-
|
|
137
|
-
def post_processing(self, image: Image.Image):
|
|
138
|
-
# Save an unmodified image with no suffix
|
|
139
|
-
self._save_image(image)
|
|
140
|
-
|
|
141
|
-
top_left_x = self.top_left_x.get()
|
|
142
|
-
top_left_y = self.top_left_y.get()
|
|
143
|
-
box_width = self.box_width.get()
|
|
144
|
-
num_boxes_x = self.num_boxes_x.get()
|
|
145
|
-
num_boxes_y = self.num_boxes_y.get()
|
|
146
|
-
assert isinstance(filename_str := self.filename.get(), str)
|
|
147
|
-
assert isinstance(directory_str := self.directory.get(), str)
|
|
148
|
-
add_grid_border_overlay_to_image(
|
|
149
|
-
image, top_left_x, top_left_y, box_width, num_boxes_x, num_boxes_y
|
|
150
|
-
)
|
|
151
|
-
path = path_join(directory_str, f"{filename_str}_outer_overlay.png")
|
|
152
|
-
self.last_path_outer.put(path)
|
|
153
|
-
LOGGER.info(f"Saving grid outer edge at {path}")
|
|
154
|
-
image.save(path)
|
|
155
|
-
add_grid_overlay_to_image(
|
|
156
|
-
image, top_left_x, top_left_y, box_width, num_boxes_x, num_boxes_y
|
|
157
|
-
)
|
|
158
|
-
path = path_join(directory_str, f"{filename_str}_grid_overlay.png")
|
|
159
|
-
self.last_path_full_overlay.put(path)
|
|
160
|
-
LOGGER.info(f"Saving full grid overlay at {path}")
|
|
161
|
-
image.save(path)
|