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.
Files changed (52) hide show
  1. {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/METADATA +3 -3
  2. {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/RECORD +50 -42
  3. {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/WHEEL +1 -1
  4. dodal/__init__.py +8 -0
  5. dodal/_version.py +2 -2
  6. dodal/beamline_specific_utils/i03.py +6 -2
  7. dodal/beamlines/__init__.py +2 -3
  8. dodal/beamlines/i03.py +41 -9
  9. dodal/beamlines/i04.py +26 -4
  10. dodal/beamlines/i10.py +257 -0
  11. dodal/beamlines/i22.py +1 -2
  12. dodal/beamlines/i24.py +7 -7
  13. dodal/beamlines/p38.py +1 -2
  14. dodal/common/types.py +2 -7
  15. dodal/devices/apple2_undulator.py +602 -0
  16. dodal/devices/areadetector/plugins/CAM.py +31 -0
  17. dodal/devices/areadetector/plugins/MJPG.py +51 -106
  18. dodal/devices/backlight.py +7 -6
  19. dodal/devices/diamond_filter.py +47 -0
  20. dodal/devices/eiger.py +6 -2
  21. dodal/devices/eiger_odin.py +48 -39
  22. dodal/devices/focusing_mirror.py +14 -8
  23. dodal/devices/i10/i10_apple2.py +398 -0
  24. dodal/devices/i10/i10_setting_data.py +7 -0
  25. dodal/devices/i22/dcm.py +7 -8
  26. dodal/devices/i24/dual_backlight.py +5 -5
  27. dodal/devices/oav/oav_calculations.py +22 -0
  28. dodal/devices/oav/oav_detector.py +118 -83
  29. dodal/devices/oav/oav_parameters.py +50 -104
  30. dodal/devices/oav/oav_to_redis_forwarder.py +75 -34
  31. dodal/devices/oav/{grid_overlay.py → snapshots/grid_overlay.py} +0 -43
  32. dodal/devices/oav/snapshots/snapshot_with_beam_centre.py +64 -0
  33. dodal/devices/oav/snapshots/snapshot_with_grid.py +57 -0
  34. dodal/devices/oav/utils.py +26 -25
  35. dodal/devices/pgm.py +41 -0
  36. dodal/devices/qbpm.py +18 -0
  37. dodal/devices/robot.py +2 -2
  38. dodal/devices/smargon.py +2 -2
  39. dodal/devices/tetramm.py +2 -2
  40. dodal/devices/undulator.py +2 -1
  41. dodal/devices/util/adjuster_plans.py +1 -1
  42. dodal/devices/util/lookup_tables.py +4 -5
  43. dodal/devices/zebra.py +5 -2
  44. dodal/devices/zocalo/zocalo_results.py +13 -10
  45. dodal/plans/data_session_metadata.py +2 -2
  46. dodal/plans/motor_util_plans.py +11 -9
  47. dodal/utils.py +7 -0
  48. dodal/beamlines/i04_1.py +0 -140
  49. dodal/devices/oav/oav_errors.py +0 -35
  50. {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/LICENSE +0 -0
  51. {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/entry_points.txt +0 -0
  52. {dls_dodal-1.33.0.dist-info → dls_dodal-1.34.1.dist-info}/top_level.txt +0 -0
@@ -1,26 +1,35 @@
1
- # type: ignore # OAV will soon be ophyd-async, see https://github.com/DiamondLightSource/dodal/issues/716
2
- from functools import partial
3
-
4
- from ophyd import ADComponent as ADC
5
- from ophyd import (
6
- AreaDetector,
7
- CamBase,
8
- Component,
9
- Device,
10
- EpicsSignal,
11
- HDF5Plugin,
12
- OverlayPlugin,
13
- ProcessPlugin,
14
- ROIPlugin,
15
- StatusBase,
16
- )
17
-
18
- from dodal.devices.areadetector.plugins.MJPG import SnapshotWithBeamCentre
19
- from dodal.devices.oav.grid_overlay import SnapshotWithGrid
20
- from dodal.devices.oav.oav_parameters import OAVConfigParams
21
-
22
-
23
- class ZoomController(Device):
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
- percentage = Component(EpicsSignal, "ZOOMPOSCMD")
34
-
35
- # Level is the string description of the zoom level e.g. "1.0x"
36
- level = Component(EpicsSignal, "MP:SELECT", string=True)
37
-
38
- zrst = Component(EpicsSignal, "MP:SELECT.ZRST")
39
- onst = Component(EpicsSignal, "MP:SELECT.ONST")
40
- twst = Component(EpicsSignal, "MP:SELECT.TWST")
41
- thst = Component(EpicsSignal, "MP:SELECT.THST")
42
- frst = Component(EpicsSignal, "MP:SELECT.FRST")
43
- fvst = Component(EpicsSignal, "MP:SELECT.FVST")
44
- sxst = Component(EpicsSignal, "MP:SELECT.SXST")
45
-
46
- @property
47
- def allowed_zoom_levels(self):
48
- return [
49
- self.zrst.get(),
50
- self.onst.get(),
51
- self.twst.get(),
52
- self.thst.get(),
53
- self.frst.get(),
54
- self.fvst.get(),
55
- self.sxst.get(),
56
- ]
57
-
58
- def set(self, level_to_set: str) -> StatusBase:
59
- return self.level.set(level_to_set)
60
-
61
-
62
- class OAV(AreaDetector):
63
- cam = ADC(CamBase, "-DI-OAV-01:CAM:")
64
- roi = ADC(ROIPlugin, "-DI-OAV-01:ROI:")
65
- proc = ADC(ProcessPlugin, "-DI-OAV-01:PROC:")
66
- over = ADC(OverlayPlugin, "-DI-OAV-01:OVER:")
67
- tiff = ADC(OverlayPlugin, "-DI-OAV-01:TIFF:")
68
- hdf5 = ADC(HDF5Plugin, "-DI-OAV-01:HDF5:")
69
- grid_snapshot = Component(SnapshotWithGrid, "-DI-OAV-01:MJPG:")
70
- snapshot = Component(SnapshotWithBeamCentre, "-DI-OAV-01:MJPG:")
71
- zoom_controller = Component(ZoomController, "-EA-OAV-01:FZOOM:")
72
-
73
- def __init__(self, *args, params: OAVConfigParams, **kwargs):
74
- super().__init__(*args, **kwargs)
75
- self.parameters = params
76
- self.grid_snapshot.oav_params = params
77
- self.snapshot.oav_params = params
78
- self.subscription_id = None
79
- self._snapshot_trigger_subscription_id = None
80
-
81
- def wait_for_connection(self, all_signals=False, timeout=2):
82
- connected = super().wait_for_connection(all_signals, timeout)
83
- x = self.grid_snapshot.x_size.get()
84
- y = self.grid_snapshot.y_size.get()
85
-
86
- cb = partial(self.parameters.update_on_zoom, xsize=x, ysize=y)
87
-
88
- if self.subscription_id is not None:
89
- self.zoom_controller.level.unsubscribe(self.subscription_id)
90
- self.subscription_id = self.zoom_controller.level.subscribe(cb)
91
-
92
- return connected
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
- class OAVConfigParams:
113
- """
114
- The OAV parameters which may update depending on settings such as the zoom level.
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
- def get_beam_position_from_zoom(
165
- self, zoom: float, xsize: int, ysize: int
166
- ) -> tuple[int, int]:
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
- beam_centre_i = (
189
- int(crosshair_x_line.split(" = ")[1]) * xsize / DEFAULT_OAV_WINDOW[0]
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 calculate_beam_distance(
198
- self, horizontal_pixels: int, vertical_pixels: int
199
- ) -> tuple[int, int]:
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
- return (
212
- self.beam_centre_i - horizontal_pixels,
213
- self.beam_centre_j - vertical_pixels,
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.stream_url = epics_signal_r(str, f"{prefix}MJPG:MJPG_URL_RBV")
79
+ self.counter = epics_signal_r(int, f"{prefix}CAM:ArrayCounter_RBV")
63
80
 
64
- with self.add_children_as_readables():
65
- self.uuid, self.uuid_setter = soft_signal_r_and_setter(str)
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 = False
94
+ self._stop_flag = asyncio.Event()
73
95
 
74
96
  self.sample_id = soft_signal_rw(int, initial_value=0)
75
97
 
76
- # The uuid that images are being saved under, this should be monitored for
77
- # callbacks to correlate the data
78
- self.uuid, self.uuid_setter = soft_signal_r_and_setter(str)
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(self, response: ClientResponse):
83
- """Converts the data that comes in as a jpeg byte stream into a numpy array of
84
- RGB values, pickles this array then writes it to redis.
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(image_uuid := str(uuid.uuid4()))
88
- img = Image.open(io.BytesIO(jpeg_bytes))
89
- image_data = pickle.dumps(np.asarray(img))
90
- sample_id = str(await self.sample_id.get_value())
91
- await self.redis_client.hset(sample_id, image_uuid, image_data) # type: ignore
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, str | None], Awaitable]
119
+ self, function_to_do: Callable[[ClientResponse, OAVSource], Awaitable]
97
120
  ):
98
- stream_url = await self.stream_url.get_value()
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, stream_url)
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
- async def _stream_to_redis(self, response, _):
104
- while not self._stop_flag:
105
- await self._get_frame_and_put_to_redis(response)
106
- await asyncio.sleep(0.01)
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, stream_url):
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"{stream_url} is not an MJPG stream")
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 = False
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
- self._stop_flag = True
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)