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.
@@ -10,6 +10,7 @@ import numpy as np
10
10
  import workflows.recipe
11
11
  import workflows.transport
12
12
  from bluesky.protocols import Descriptor, Triggerable
13
+ from deepdiff import DeepDiff
13
14
  from numpy.typing import NDArray
14
15
  from ophyd_async.core import (
15
16
  AsyncStatus,
@@ -37,6 +38,11 @@ class SortKeys(str, Enum):
37
38
  n_voxels = "n_voxels"
38
39
 
39
40
 
41
+ class ZocaloSource(str, Enum):
42
+ CPU = "CPU"
43
+ GPU = "GPU"
44
+
45
+
40
46
  DEFAULT_TIMEOUT = 180
41
47
  DEFAULT_SORT_KEY = SortKeys.max_count
42
48
  ZOCALO_READING_PLAN_NAME = "zocalo reading"
@@ -60,12 +66,50 @@ def bbox_size(result: XrcResult):
60
66
  ]
61
67
 
62
68
 
69
+ def get_dict_differences(
70
+ dict1: dict, dict1_source: str, dict2: dict, dict2_source: str
71
+ ) -> str | None:
72
+ """Returns a string containing dict1 and dict2 if there are differences between them, greater than a
73
+ 1e-5 tolerance. If dictionaries are identical, return None"""
74
+
75
+ diff = DeepDiff(dict1, dict2, math_epsilon=1e-5, ignore_numeric_type_changes=True)
76
+
77
+ if diff:
78
+ return f"Zocalo results from {dict1_source} and {dict2_source} are not identical.\n Results from {dict1_source}: {dict1}\n Results from {dict2_source}: {dict2}"
79
+
80
+
81
+ def source_from_results(results):
82
+ return (
83
+ ZocaloSource.GPU.value
84
+ if results["recipe_parameters"].get("gpu")
85
+ else ZocaloSource.CPU.value
86
+ )
87
+
88
+
63
89
  class ZocaloResults(StandardReadable, Triggerable):
64
90
  """An ophyd device which can wait for results from a Zocalo job. These jobs should
65
91
  be triggered from a plan-subscribed callback using the run_start() and run_end()
66
92
  methods on dodal.devices.zocalo.ZocaloTrigger.
67
93
 
68
- See https://github.com/DiamondLightSource/dodal/wiki/How-to-Interact-with-Zocalo"""
94
+ See https://diamondlightsource.github.io/dodal/main/how-to/zocalo.html
95
+
96
+ Args:
97
+ name (str): Name of the device
98
+
99
+ zocalo_environment (str): How zocalo is configured. Defaults to i03's development configuration
100
+
101
+ channel (str): Name for the results Queue
102
+
103
+ sort_key (str): How results are ranked. Defaults to sorting by highest counts
104
+
105
+ timeout_s (float): Maximum time to wait for the Queue to be filled by an object, starting
106
+ from when the ZocaloResults device is triggered
107
+
108
+ prefix (str): EPICS PV prefix for the device
109
+
110
+ use_cpu_and_gpu (bool): When True, ZocaloResults will wait for results from the CPU and the GPU, compare them, and provide a warning if the results differ. When False, ZocaloResults will only use results from the CPU
111
+
112
+ """
69
113
 
70
114
  def __init__(
71
115
  self,
@@ -75,6 +119,7 @@ class ZocaloResults(StandardReadable, Triggerable):
75
119
  sort_key: str = DEFAULT_SORT_KEY.value,
76
120
  timeout_s: float = DEFAULT_TIMEOUT,
77
121
  prefix: str = "",
122
+ use_cpu_and_gpu: bool = False,
78
123
  ) -> None:
79
124
  self.zocalo_environment = zocalo_environment
80
125
  self.sort_key = SortKeys[sort_key]
@@ -83,6 +128,7 @@ class ZocaloResults(StandardReadable, Triggerable):
83
128
  self._prefix = prefix
84
129
  self._raw_results_received: Queue = Queue()
85
130
  self.transport: CommonTransport | None = None
131
+ self.use_cpu_and_gpu = use_cpu_and_gpu
86
132
 
87
133
  self.results, self._results_setter = soft_signal_r_and_setter(
88
134
  list[XrcResult], name="results"
@@ -111,14 +157,14 @@ class ZocaloResults(StandardReadable, Triggerable):
111
157
  )
112
158
  super().__init__(name)
113
159
 
114
- async def _put_results(self, results: Sequence[XrcResult], ispyb_ids):
160
+ async def _put_results(self, results: Sequence[XrcResult], recipe_parameters):
115
161
  self._results_setter(list(results))
116
162
  centres_of_mass = np.array([r["centre_of_mass"] for r in results])
117
163
  bbox_sizes = np.array([bbox_size(r) for r in results])
118
164
  self._com_setter(centres_of_mass)
119
165
  self._bbox_setter(bbox_sizes)
120
- self._ispyb_dcid_setter(ispyb_ids["dcid"])
121
- self._ispyb_dcgid_setter(ispyb_ids["dcgid"])
166
+ self._ispyb_dcid_setter(recipe_parameters["dcid"])
167
+ self._ispyb_dcgid_setter(recipe_parameters["dcgid"])
122
168
 
123
169
  def _clear_old_results(self):
124
170
  LOGGER.info("Clearing queue")
@@ -127,7 +173,7 @@ class ZocaloResults(StandardReadable, Triggerable):
127
173
  @AsyncStatus.wrap
128
174
  async def stage(self):
129
175
  """Stages the Zocalo device by: subscribing to the queue, doing a background
130
- sleep for a few seconds to wait for any stale messages to be recieved, then
176
+ sleep for a few seconds to wait for any stale messages to be received, then
131
177
  clearing the queue. Plans using this device should wait on ZOCALO_STAGE_GROUP
132
178
  before triggering processing for the experiment"""
133
179
 
@@ -169,7 +215,57 @@ class ZocaloResults(StandardReadable, Triggerable):
169
215
  )
170
216
 
171
217
  raw_results = self._raw_results_received.get(timeout=self.timeout_s)
172
- LOGGER.info(f"Zocalo: found {len(raw_results['results'])} crystals.")
218
+ source_of_first_results = source_from_results(raw_results)
219
+
220
+ # Wait for results from CPU and GPU, warn and continue if only GPU times out. Error if CPU times out
221
+ if self.use_cpu_and_gpu:
222
+ if source_of_first_results == ZocaloSource.CPU:
223
+ LOGGER.warning("Received zocalo results from CPU before GPU")
224
+ raw_results_two_sources = [raw_results]
225
+ try:
226
+ raw_results_two_sources.append(
227
+ self._raw_results_received.get(timeout=self.timeout_s / 2)
228
+ )
229
+ source_of_second_results = source_from_results(
230
+ raw_results_two_sources[1]
231
+ )
232
+
233
+ # Compare results from both sources and warn if they aren't the same
234
+ differences_str = get_dict_differences(
235
+ raw_results_two_sources[0]["results"][0],
236
+ source_of_first_results,
237
+ raw_results_two_sources[1]["results"][0],
238
+ source_of_second_results,
239
+ )
240
+ if differences_str:
241
+ LOGGER.warning(differences_str)
242
+
243
+ # Always use CPU results
244
+ raw_results = (
245
+ raw_results_two_sources[0]
246
+ if source_of_first_results == ZocaloSource.CPU
247
+ else raw_results_two_sources[1]
248
+ )
249
+
250
+ except Empty as err:
251
+ source_of_missing_results = (
252
+ ZocaloSource.CPU.value
253
+ if source_of_first_results == ZocaloSource.GPU.value
254
+ else ZocaloSource.GPU.value
255
+ )
256
+ if source_of_missing_results == ZocaloSource.GPU.value:
257
+ LOGGER.warning(
258
+ f"Zocalo results from {source_of_missing_results} timed out. Using results from {source_of_first_results}"
259
+ )
260
+ else:
261
+ LOGGER.error(
262
+ f"Zocalo results from {source_of_missing_results} timed out and GPU results not yet reliable"
263
+ )
264
+ raise err
265
+
266
+ LOGGER.info(
267
+ f"Zocalo results from {ZocaloSource.CPU.value} processing: found {len(raw_results['results'])} crystals."
268
+ )
173
269
  # Sort from strongest to weakest in case of multiple crystals
174
270
  await self._put_results(
175
271
  sorted(
@@ -177,7 +273,7 @@ class ZocaloResults(StandardReadable, Triggerable):
177
273
  key=lambda d: d[self.sort_key.value],
178
274
  reverse=True,
179
275
  ),
180
- raw_results["ispyb_ids"],
276
+ raw_results["recipe_parameters"],
181
277
  )
182
278
  except Empty as timeout_exception:
183
279
  LOGGER.warning("Timed out waiting for zocalo results!")
@@ -241,9 +337,17 @@ class ZocaloResults(StandardReadable, Triggerable):
241
337
  self.transport.ack(header) # type: ignore # we create transport here
242
338
 
243
339
  results = message.get("results", [])
244
- self._raw_results_received.put(
245
- {"results": results, "ispyb_ids": recipe_parameters}
246
- )
340
+
341
+ if self.use_cpu_and_gpu:
342
+ self._raw_results_received.put(
343
+ {"results": results, "recipe_parameters": recipe_parameters}
344
+ )
345
+ else:
346
+ # Only add to queue if results are from CPU
347
+ if not recipe_parameters.get("gpu"):
348
+ self._raw_results_received.put(
349
+ {"results": results, "recipe_parameters": recipe_parameters}
350
+ )
247
351
 
248
352
  subscription = workflows.recipe.wrap_subscribe(
249
353
  self.transport,
dodal/utils.py CHANGED
@@ -13,6 +13,7 @@ from os import environ
13
13
  from types import ModuleType
14
14
  from typing import (
15
15
  Any,
16
+ TypeGuard,
16
17
  TypeVar,
17
18
  )
18
19
 
@@ -259,7 +260,7 @@ def _is_device_skipped(func: AnyDeviceFactory) -> bool:
259
260
  return getattr(func, "__skip__", False)
260
261
 
261
262
 
262
- def is_v1_device_factory(func: Callable) -> bool:
263
+ def is_v1_device_factory(func: Callable) -> TypeGuard[V1DeviceFactory]:
263
264
  try:
264
265
  return_type = signature(func).return_annotation
265
266
  return is_v1_device_type(return_type)
@@ -267,7 +268,7 @@ def is_v1_device_factory(func: Callable) -> bool:
267
268
  return False
268
269
 
269
270
 
270
- def is_v2_device_factory(func: Callable) -> bool:
271
+ def is_v2_device_factory(func: Callable) -> TypeGuard[V2DeviceFactory]:
271
272
  try:
272
273
  return_type = signature(func).return_annotation
273
274
  return is_v2_device_type(return_type)
@@ -275,7 +276,7 @@ def is_v2_device_factory(func: Callable) -> bool:
275
276
  return False
276
277
 
277
278
 
278
- def is_any_device_factory(func: Callable) -> bool:
279
+ def is_any_device_factory(func: Callable) -> TypeGuard[AnyDeviceFactory]:
279
280
  return is_v1_device_factory(func) or is_v2_device_factory(func)
280
281
 
281
282