lima2.client 2.0.2__tar.gz → 2.1.0__tar.gz

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 (53) hide show
  1. {lima2_client-2.0.2/src/lima2.client.egg-info → lima2_client-2.1.0}/PKG-INFO +2 -1
  2. {lima2_client-2.0.2 → lima2_client-2.1.0}/setup.cfg +2 -1
  3. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/client.py +79 -16
  4. lima2_client-2.1.0/src/lima2/client/convert.py +36 -0
  5. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/detector.py +31 -47
  6. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/devencoded/dense_frame.py +4 -8
  7. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/devencoded/sparse_frame.py +1 -1
  8. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/pipeline.py +88 -15
  9. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/shell.py +4 -2
  10. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/state_machine.py +53 -8
  11. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/utils.py +30 -0
  12. lima2_client-2.1.0/src/lima2/master_file/generate.py +222 -0
  13. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/server/receiver.py +4 -0
  14. {lima2_client-2.0.2 → lima2_client-2.1.0/src/lima2.client.egg-info}/PKG-INFO +2 -1
  15. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2.client.egg-info/SOURCES.txt +4 -3
  16. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2.client.egg-info/requires.txt +1 -0
  17. {lima2_client-2.0.2 → lima2_client-2.1.0}/tests/test_client.py +118 -0
  18. lima2_client-2.1.0/tests/test_convert.py +39 -0
  19. lima2_client-2.1.0/tests/test_dense_frame.py +155 -0
  20. {lima2_client-2.0.2 → lima2_client-2.1.0}/tests/test_detector.py +1 -0
  21. {lima2_client-2.0.2 → lima2_client-2.1.0}/tests/test_pipeline.py +152 -0
  22. {lima2_client-2.0.2 → lima2_client-2.1.0}/tests/test_progress_counter.py +5 -0
  23. lima2_client-2.1.0/tests/test_sparse_frame.py +109 -0
  24. {lima2_client-2.0.2 → lima2_client-2.1.0}/tests/test_state_machine.py +19 -3
  25. {lima2_client-2.0.2 → lima2_client-2.1.0}/tests/test_timer.py +2 -0
  26. {lima2_client-2.0.2 → lima2_client-2.1.0}/tests/test_topology.py +5 -0
  27. lima2_client-2.1.0/tests/test_utils.py +62 -0
  28. lima2_client-2.0.2/src/lima2/client/channel.py +0 -30
  29. lima2_client-2.0.2/src/lima2/client/convert.py +0 -46
  30. lima2_client-2.0.2/src/lima2/client/devencoded/sparse_frame_with_background.py +0 -120
  31. lima2_client-2.0.2/tests/test_import.py +0 -10
  32. lima2_client-2.0.2/tests/test_utils.py +0 -30
  33. {lima2_client-2.0.2 → lima2_client-2.1.0}/LICENSE +0 -0
  34. {lima2_client-2.0.2 → lima2_client-2.1.0}/README.md +0 -0
  35. {lima2_client-2.0.2 → lima2_client-2.1.0}/pyproject.toml +0 -0
  36. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/__init__.py +0 -0
  37. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/devencoded/__init__.py +0 -0
  38. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/devencoded/exception.py +0 -0
  39. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/devencoded/structured_array.py +0 -0
  40. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/pipelines/__init__.py +0 -0
  41. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/pipelines/legacy.py +0 -0
  42. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/pipelines/smx.py +0 -0
  43. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/pipelines/xpcs.py +0 -0
  44. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/progress_counter.py +0 -0
  45. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/py.typed +0 -0
  46. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/client/topology.py +0 -0
  47. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/server/__init__.py +0 -0
  48. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/server/control.py +0 -0
  49. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2/server/timer.py +0 -0
  50. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2.client.egg-info/dependency_links.txt +0 -0
  51. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2.client.egg-info/entry_points.txt +0 -0
  52. {lima2_client-2.0.2 → lima2_client-2.1.0}/src/lima2.client.egg-info/top_level.txt +0 -0
  53. {lima2_client-2.0.2 → lima2_client-2.1.0}/tests/test_pipelines.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.2
2
2
  Name: lima2.client
3
- Version: 2.0.2
3
+ Version: 2.1.0
4
4
  Summary: Python client library for Lima2 detector
5
5
  Home-page: https://gitlab.esrf.fr/limagroup/lima2-client
6
6
  Author: BCU (ESRF)
@@ -19,6 +19,7 @@ Requires-Dist: beartype
19
19
  Requires-Dist: numpy
20
20
  Requires-Dist: pyyaml
21
21
  Requires-Dist: typing-extensions
22
+ Requires-Dist: h5py
22
23
  Provides-Extra: shell
23
24
  Requires-Dist: ipython; extra == "shell"
24
25
 
@@ -22,6 +22,7 @@ install_requires =
22
22
  numpy
23
23
  pyyaml
24
24
  typing-extensions
25
+ h5py
25
26
 
26
27
  [options.entry_points]
27
28
  console_scripts =
@@ -40,7 +41,7 @@ per-file-ignores =
40
41
  required_plugins =
41
42
  pytest-cov>=6.0.0
42
43
  pytest-forked>=1.6.0
43
- addopts = -v --forked
44
+ addopts = -v
44
45
  pythonpath = src
45
46
 
46
47
  [coverage:run]
@@ -18,7 +18,10 @@ individual receivers, for instance if one of them is stuck on a frame while the
18
18
  processing data.
19
19
  """
20
20
 
21
+ import logging
21
22
  import os
23
+ import traceback
24
+ from concurrent import futures
22
25
  from uuid import UUID
23
26
 
24
27
  import tango as tg
@@ -33,6 +36,8 @@ from lima2.client.progress_counter import ProgressCounter, SingleCounter
33
36
  from lima2.client.topology import TopologyKind
34
37
  from lima2.client.utils import docstring_from
35
38
 
39
+ logger = logging.getLogger(__name__)
40
+
36
41
 
37
42
  @beartype
38
43
  class Client:
@@ -67,12 +72,10 @@ class Client:
67
72
  f"No matching topology type for {repr(topology_name)}"
68
73
  ) from e
69
74
 
70
- self.recv_devs = rcv_devs
71
-
72
75
  # Cache of processing pipelines by uuid (see method `pipeline`)
73
76
  self.__pipelines: dict[UUID, Pipeline] = {}
74
77
 
75
- self.tango_db = tg.Database()
78
+ self._tango_db = tg.Database()
76
79
 
77
80
  @classmethod
78
81
  def from_yaml(
@@ -91,6 +94,16 @@ class Client:
91
94
 
92
95
  return Client(ctl_dev=ctl_dev, rcv_devs=rcv_devs)
93
96
 
97
+ @property
98
+ def control(self) -> tg.DeviceProxy:
99
+ """Lima2 control tango device."""
100
+ return self.detector.ctrl
101
+
102
+ @property
103
+ def receivers(self) -> list[tg.DeviceProxy]:
104
+ """Lima2 receiver tango devices."""
105
+ return self.detector.recvs
106
+
94
107
  @property
95
108
  def detector(self) -> Detector:
96
109
  return self._detector
@@ -124,6 +137,11 @@ class Client:
124
137
  def params_default(self) -> dict[str, Any]:
125
138
  return self.detector.params_default
126
139
 
140
+ @property
141
+ @docstring_from(Detector.params_schema)
142
+ def params_schema(self) -> dict[str, Any]:
143
+ return self.detector.params_schema
144
+
127
145
  ####################################################################################
128
146
  # Control
129
147
  ####################################################################################
@@ -138,7 +156,7 @@ class Client:
138
156
  ctl_params=ctrl_params,
139
157
  acq_params=acq_params,
140
158
  proc_params=proc_params,
141
- num_receivers=len(self.recv_devs),
159
+ num_receivers=len(self.receivers),
142
160
  )
143
161
 
144
162
  self.detector.prepare_acq(uuid, ctl, acq, proc)
@@ -170,7 +188,7 @@ class Client:
170
188
  SingleCounter(
171
189
  name="nb_frames_acquired",
172
190
  value=self.detector.nb_frames_acquired,
173
- source=self.detector.ctrl.name(),
191
+ source=self.control.name(),
174
192
  )
175
193
  )
176
194
 
@@ -184,7 +202,7 @@ class Client:
184
202
  value=recv.nb_frames_xferred,
185
203
  source=recv.name(),
186
204
  )
187
- for recv in self.detector.recvs
205
+ for recv in self.receivers
188
206
  ]
189
207
  )
190
208
 
@@ -195,7 +213,7 @@ class Client:
195
213
  @property
196
214
  def pipelines(self) -> list[UUID]:
197
215
  """Get list of available pipelines UUID"""
198
- uuids = [recv.pipelines for recv in self.recv_devs]
216
+ uuids = [recv.pipelines for recv in self.receivers]
199
217
  if all(uuid is None for uuid in uuids):
200
218
  return []
201
219
  return [UUID(u) for u in {y for x in uuids for y in x}]
@@ -209,12 +227,13 @@ class Client:
209
227
  return self.__pipelines[uuid]
210
228
 
211
229
  # uuid isn't in the local cache of pipelines: get it from Tango
212
- instances = self.tango_db.get_device_exported(f"*/limaprocessing/{uuid}*")
230
+ instances = self._tango_db.get_device_exported(f"*/limaprocessing/{uuid}*")
213
231
  if not instances:
214
232
  raise ValueError(f"Pipeline not found in tango database: {uuid=}")
215
233
 
216
234
  class_names = [
217
- self.tango_db.get_device_info(instance).class_name for instance in instances
235
+ self._tango_db.get_device_info(instance).class_name
236
+ for instance in instances
218
237
  ]
219
238
 
220
239
  # Select the pipeline according to the Tango class (Homogeneous processing for now)
@@ -237,7 +256,7 @@ class Client:
237
256
  Raises if no pipeline has been created, or if receivers disagree on the
238
257
  current pipeline's uuid.
239
258
  """
240
- uuids: list[str] = [recv.current_pipeline for recv in self.recv_devs]
259
+ uuids: list[str] = [recv.current_pipeline for recv in self.receivers]
241
260
 
242
261
  if all([uuid == "" for uuid in uuids]):
243
262
  raise ValueError("No pipeline present in the tango database")
@@ -250,10 +269,54 @@ class Client:
250
269
  return self.pipeline(uuids[0])
251
270
 
252
271
  def erase_pipeline(self, uuid: UUID) -> None:
253
- """Erase a pipeline instance"""
254
- self.__pipelines.pop(uuid, None)
255
- for recv in self.recv_devs:
272
+ """Erase a pipeline instance.
273
+
274
+ Raises if the pipeline is absent from a receiver.
275
+ """
276
+ for recv in self.receivers:
256
277
  recv.erasePipeline(str(uuid))
278
+ self.__pipelines.pop(uuid, None)
279
+
280
+ def clear_previous_pipelines(self) -> None:
281
+ """Erase all pipelines except the current one."""
282
+ try:
283
+ current = self.current_pipeline.uuid
284
+ except ValueError:
285
+ current = None
286
+
287
+ def clear(receiver: tg.DeviceProxy):
288
+ """Clear previous pipelines from a single receiver device."""
289
+ if receiver.pipelines is None:
290
+ # No pipelines
291
+ return []
292
+
293
+ cleared = []
294
+ for uuid in receiver.pipelines:
295
+ if uuid != str(current):
296
+ logger.debug(
297
+ f"Erasing pipeline {uuid} from recv {receiver.dev_name()}"
298
+ )
299
+ receiver.erasePipeline(uuid)
300
+ cleared.append(uuid)
301
+ return cleared
302
+
303
+ cleared: set[str] = set()
304
+ with futures.ThreadPoolExecutor(max_workers=len(self.receivers)) as executor:
305
+ fs = [executor.submit(clear, receiver) for receiver in self.receivers]
306
+ for receiver, future in zip(self.receivers, fs):
307
+ try:
308
+ cleared = cleared.union(future.result())
309
+ except Exception:
310
+ logger.warning(
311
+ f"Exception while clearing pipelines from {receiver.dev_name()}: "
312
+ f"{traceback.format_exc()}"
313
+ )
314
+
315
+ logger.debug(f"Cleared pipelines {[uuid for uuid in set(cleared)]}")
316
+
317
+ # Reset local cache
318
+ if current is not None:
319
+ self.__pipelines = {current: self.__pipelines[current]}
257
320
 
258
321
  ####################################################################################
259
322
  # Utility
@@ -264,10 +327,10 @@ class Client:
264
327
  [
265
328
  "Lima2 client",
266
329
  f"State: {self.detector.state}",
267
- "Controller:",
268
- f" {self.detector.ctrl}",
330
+ "Control:",
331
+ f" {self.control}",
269
332
  f"Receivers ({self.topology_kind}):",
270
- *[f" - {recv}" for recv in self.detector.recvs],
333
+ *[f" - {recv}" for recv in self.receivers],
271
334
  "Progress counters:",
272
335
  f" - {self.nb_frames_acquired}",
273
336
  f" - {self.nb_frames_xferred}",
@@ -0,0 +1,36 @@
1
+ # -*- coding: utf-8 -*-
2
+ #
3
+ # This file is part of the Lima2 project
4
+ #
5
+ # Copyright (c) 2020-2024 Beamline Control Unit, ESRF
6
+ # Distributed under the MIT license. See LICENSE for more info.
7
+
8
+ """Convertion routines.
9
+
10
+ Frame info is serialized as {'dimensions': {'x': 2068, 'y': 512}, 'nb_channels': 2, 'pixel_type': 'gray16'}
11
+ """
12
+
13
+ import numpy as np
14
+
15
+ # convert from pixel enum type to numpy type
16
+ pixel_type_to_np_dtype = {
17
+ "gray8s": np.int8,
18
+ "gray8": np.uint8,
19
+ "gray16s": np.int16,
20
+ "gray16": np.uint16,
21
+ "gray32s": np.int32,
22
+ "gray32": np.uint32,
23
+ "gray32f": np.float32,
24
+ "gray64f": np.float64,
25
+ }
26
+
27
+
28
+ def frame_info_to_shape_dtype(frame_info):
29
+ return dict(
30
+ shape=(
31
+ frame_info["nb_channels"],
32
+ frame_info["dimensions"]["y"],
33
+ frame_info["dimensions"]["x"],
34
+ ),
35
+ dtype=pixel_type_to_np_dtype[frame_info["pixel_type"]],
36
+ )
@@ -24,13 +24,15 @@ import json
24
24
  import logging
25
25
  import uuid
26
26
 
27
- import jsonschema
28
27
  import tango
29
28
  from beartype import beartype
30
29
  from jsonschema_default import create_from
30
+ from typing_extensions import Optional
31
31
 
32
+ from lima2.client import pipelines
32
33
  from lima2.client import state_machine as fsm
33
34
  from lima2.client.state_machine import State
35
+ from lima2.client.utils import validate
34
36
 
35
37
  # Create a logger
36
38
  _logger = logging.getLogger(__name__)
@@ -45,7 +47,7 @@ class CommError(RuntimeError):
45
47
  class Detector:
46
48
  """The main Detector interface."""
47
49
 
48
- DEFAULT_TANGO_TIMEOUT = 20 * 60 #
50
+ DEFAULT_TANGO_TIMEOUT = 5
49
51
 
50
52
  def __init__(
51
53
  self,
@@ -72,12 +74,7 @@ class Detector:
72
74
  self.__ctrl = ctrl_dev
73
75
  self.__recvs = list(recv_devs)
74
76
 
75
- try:
76
- self.ping()
77
- except tango.DevFailed as e:
78
- raise CommError(
79
- "Failed to ping Tango servers (are the devices running?)"
80
- ) from e
77
+ self.ping()
81
78
 
82
79
  rank_order = [d.recv_rank for d in recv_devs]
83
80
  if not all([r == i for i, r in enumerate(rank_order)]):
@@ -85,7 +82,7 @@ class Detector:
85
82
  f"Receiver MPI rank order does not match client order: {rank_order}"
86
83
  )
87
84
 
88
- self.__params_schema = None
85
+ self.__params_schema: Optional[dict] = None
89
86
  self.__fsm = fsm.StateMachine(self.__ctrl, self.__recvs)
90
87
  self.__try_sync_hard()
91
88
 
@@ -100,8 +97,6 @@ class Detector:
100
97
  d.set_green_mode(tango.GreenMode.Gevent)
101
98
  d.set_timeout_millis(timeout * 1000)
102
99
 
103
- self.__try_params_schema()
104
-
105
100
  @property
106
101
  def det_info(self) -> dict:
107
102
  """
@@ -166,26 +161,19 @@ class Detector:
166
161
  """
167
162
  return json.loads(self.__ctrl.det_capabilities) # type: ignore
168
163
 
169
- def __try_params_schema(self):
170
- """
171
- Try to init the parameters from a lazy constructed device Proxy
172
- (to be used when the DS is not running when Detector is constructed)
173
- """
174
- try:
175
- if not self.__params_schema:
176
- self.__params_schema = self.__get_params_schema()
177
- return True
178
- except (tango.DevFailed, RuntimeError) as e:
179
- print(f"Failed to get params schema: {e}")
180
- return False
181
-
182
164
  def __get_params_schema(self) -> dict:
183
165
  """
184
166
  Get the parameters's schema for each devices from the Tango class attribute
167
+
168
+ Raises tango.DevFailed if we cannot connect to database at TANGO_HOST.
169
+
170
+ If the lima2 server hasn't been started yet, returns a dict of the form:
171
+ {
172
+ d.dev_name(): {"init_params": None, "acq_params": None}
173
+ for d in self.devs
174
+ }
185
175
  """
186
176
  tango_db = tango.Database()
187
- if tango_db is None:
188
- return {}
189
177
 
190
178
  res = {}
191
179
  for d in self.devs:
@@ -207,7 +195,7 @@ class Detector:
207
195
  return res
208
196
 
209
197
  @property
210
- def params_schema(self):
198
+ def params_schema(self) -> dict:
211
199
  """Returns JSON schemas of init, acquisition and processing parameters.
212
200
 
213
201
  Example:
@@ -235,10 +223,9 @@ class Detector:
235
223
  Group:
236
224
  Parameters
237
225
  """
238
- if self.__params_schema:
239
- return self.__params_schema
226
+ if self.__params_schema is None:
227
+ self.__params_schema = self.__get_params_schema()
240
228
 
241
- self.__try_params_schema()
242
229
  return self.__params_schema
243
230
 
244
231
  @property
@@ -281,13 +268,16 @@ class Detector:
281
268
  Ping all the devices of the system.
282
269
 
283
270
  Raises:
284
- tango.ConnectionFailed: if the connection failed.
271
+ CommError if the connection failed.
285
272
 
286
273
  Group:
287
274
  Status
288
275
  """
289
276
  for d in self.devs:
290
- d.ping()
277
+ try:
278
+ d.ping()
279
+ except tango.DevFailed as e:
280
+ raise CommError(f"Failed to ping device {d.dev_name()}") from e
291
281
 
292
282
  @property
293
283
  def devs(self) -> list[tango.DeviceProxy]:
@@ -347,18 +337,11 @@ class Detector:
347
337
  Group:
348
338
  State
349
339
  """
350
- states = [d.acq_state for d in self.devs]
351
- if not all(s == states[0] for s in states):
352
- _logger.warning(f"Inconsistant server states {states}")
353
- server_state = states[0]
354
- ServerState = type(server_state)
355
- map = {
356
- ServerState.idle: State.IDLE,
357
- ServerState.prepared: State.PREPARED,
358
- ServerState.running: State.RUNNING,
359
- ServerState.stopped: State.IDLE,
360
- }
361
- self.__fsm.state = map[server_state]
340
+ try:
341
+ self.__fsm.sync()
342
+ except Exception as e:
343
+ _logger.warning(f"Unable to sync with server: {e}")
344
+
362
345
  return self.__fsm.state
363
346
 
364
347
  @property
@@ -421,18 +404,19 @@ class Detector:
421
404
  f"prepare_acq({uuid}, {ctrl_params}, {acq_params}, {proc_params})"
422
405
  )
423
406
 
424
- jsonschema.validate(
407
+ validate(
425
408
  instance=ctrl_params,
426
409
  schema=self.params_schema[self.ctrl.name()]["acq_params"],
427
410
  )
428
411
 
429
412
  for i, recv in enumerate(self.__recvs):
430
- jsonschema.validate(
413
+ validate(
431
414
  instance=acq_params[i],
432
415
  schema=self.params_schema[recv.name()]["acq_params"],
433
416
  )
434
417
 
435
- # TODO: Validate proc_params against the schema
418
+ pipeline_class = pipelines.get_class(proc_params[i]["class_name"])
419
+ validate(instance=proc_params[i], schema=pipeline_class.params_schema)
436
420
 
437
421
  # Update parameters on the device servers
438
422
  # TODO: Rename acq_params to ctrl_params
@@ -10,11 +10,13 @@
10
10
 
11
11
  from dataclasses import dataclass
12
12
  import struct
13
- import numpy
14
13
  import typing
15
14
  from typing_extensions import Union
16
15
  import enum
17
16
 
17
+ import numpy
18
+ from typing_extensions import Union
19
+
18
20
  from .exception import DevEncodedFormatNotSupported
19
21
 
20
22
  DATA_HEADER_FORMAT = "<IHHIIHHHHHHHHIIIIIIQ"
@@ -61,15 +63,9 @@ class Frame:
61
63
  data: numpy.ndarray
62
64
  """Data of the frame"""
63
65
 
64
- idx: typing.Optional[int]
66
+ idx: int
65
67
  """Index of the frame where 0 is the first frame"""
66
68
 
67
- def __bool__(self):
68
- return not self.isEmpty()
69
-
70
- def isEmpty(self):
71
- return self.data is None
72
-
73
69
 
74
70
  def decode(raw_data: EncodedFrame) -> Frame:
75
71
  """Decode data provided by Lima device image attribute
@@ -31,7 +31,7 @@ class SparseFrame:
31
31
  index: np.ndarray
32
32
  intensity: np.ndarray
33
33
 
34
- idx: int | None
34
+ idx: int
35
35
  """Index of the frame where 0 is the first frame"""
36
36
 
37
37
  shape: tuple[int, ...]
@@ -14,6 +14,7 @@ frame index, and provide aggregated progress counters during/after an acquisitio
14
14
 
15
15
  import json
16
16
  import logging
17
+ import traceback
17
18
  from collections.abc import Callable
18
19
  from concurrent.futures import ThreadPoolExecutor
19
20
  from dataclasses import dataclass
@@ -36,7 +37,6 @@ from lima2.client.topology import (
36
37
  )
37
38
  from lima2.client.utils import classproperty
38
39
 
39
- # Create a logger
40
40
  logger = logging.getLogger(__name__)
41
41
 
42
42
 
@@ -231,14 +231,29 @@ class Pipeline:
231
231
  # The most naive way to get the latest frame is to try all receivers.
232
232
  # For monitoring purposes (~1 Hz), this is OK, but would be very wasteful otherwise.
233
233
  num_rcvs = len(self._devs)
234
+
235
+ def fetch_and_decode_last(rcv_idx):
236
+ """Return the decoded last frame from receiver rcv_idx.
237
+
238
+ Returns None if the receiver hasn't yet processed any frames.
239
+ """
240
+ try:
241
+ return frame_source.decoder(getters[rcv_idx](-1))
242
+ except tango.DevFailed:
243
+ return None
244
+
234
245
  with ThreadPoolExecutor(max_workers=num_rcvs) as pool:
235
246
  frames = list(
236
247
  pool.map(
237
- lambda rcv_idx: frame_source.decoder(getters[rcv_idx](-1)),
248
+ fetch_and_decode_last,
238
249
  range(num_rcvs),
239
250
  )
240
251
  )
241
- indices = [frame.idx for frame in frames]
252
+ indices = [frame.idx for frame in frames if frame is not None]
253
+ if len(indices) == 0:
254
+ raise ValueError(
255
+ "Cannot get the latest frame: no frames available from any receiver"
256
+ )
242
257
  latest_idx = indices.index(max(indices))
243
258
 
244
259
  logger.debug(
@@ -505,22 +520,58 @@ class Pipeline:
505
520
  """A list of `is_finished` for each devices."""
506
521
  return [dev.is_finished for dev in self._devs]
507
522
 
508
- def register_on_finished(self, cbk):
523
+ def register_on_finished(self, on_finished: Callable[[], None]) -> None:
509
524
  """
510
- Register a callback function to be notified on pipeline finish
525
+ Register a callback function to be notified when all pipelines are finished.
511
526
 
512
527
  Arg:
513
- cbk: A callback `on_finished(evt: Tango.Event)` called for each receivers
514
-
515
- Returns:
516
- A dict mapping the pipeline instance name with the event id
528
+ on_finished: A callback called when all receivers are done
529
+ processing frames.
517
530
  """
518
531
 
519
- return {
520
- proc: proc.subscribe_event(
521
- "is_finished", tango.EventType.DATA_READY_EVENT, cbk
532
+ self.finished_devs: set[tango.DeviceProxy] = set()
533
+ """Set of processing devices that have finished."""
534
+
535
+ event_ids: dict[tango.DeviceProxy, int] = {}
536
+
537
+ def device_finished_callback(evt: tango.DataReadyEventData):
538
+ nonlocal event_ids
539
+
540
+ if evt.err:
541
+ logger.error(
542
+ f"Error on {evt.device.dev_name()} 'is_finished' event:\n"
543
+ + "\n".join([f"- {err.desc}" for err in evt.errors])
544
+ + "\n"
545
+ )
546
+ for r, e in event_ids.items():
547
+ r.unsubscribe_event(e)
548
+ return
549
+
550
+ logger.debug(f"{evt.device.dev_name()} is_finished event received")
551
+
552
+ self.finished_devs.add(evt.device)
553
+ if self.finished_devs == {dev for dev in self._devs}:
554
+ # All done. Call on_finished and report any exceptions.
555
+ try:
556
+ on_finished()
557
+ except Exception:
558
+ logger.error(
559
+ "Exception raised in pipeline on_finished callback:\n"
560
+ f"{traceback.format_exc()}"
561
+ )
562
+
563
+ for dev, event_id in event_ids.items():
564
+ # Unsubscribe from the event to stop trying in case of error
565
+ logger.debug(f"Unsubscribing from {event_id} on {dev.dev_name()}")
566
+ dev.unsubscribe_event(event_id)
567
+
568
+ event_ids = {
569
+ dev: dev.subscribe_event(
570
+ "is_finished",
571
+ tango.EventType.DATA_READY_EVENT,
572
+ device_finished_callback,
522
573
  )
523
- for proc in self._devs
574
+ for dev in self._devs
524
575
  }
525
576
 
526
577
  @property
@@ -539,9 +590,31 @@ class Pipeline:
539
590
  A dict mapping the pipeline instance name with the event id
540
591
  """
541
592
 
542
- return {
593
+ event_ids: dict[tango.DeviceProxy, int] = {}
594
+
595
+ def on_error(evt: tango.DataReadyEventData):
596
+ nonlocal event_ids
597
+
598
+ if evt.err:
599
+ logger.error(
600
+ f"Error on {evt.device.dev_name()} 'last_error' event:\n"
601
+ + "\n".join([f"- {err.desc}" for err in evt.errors])
602
+ + "\n"
603
+ )
604
+ else:
605
+ try:
606
+ cbk(evt)
607
+ except Exception:
608
+ logger.error(
609
+ "Exception raised in pipeline on_error callback:\n"
610
+ f"{traceback.format_exc()}"
611
+ )
612
+
613
+ evt.device.unsubscribe_event(event_ids[evt.device])
614
+
615
+ event_ids = {
543
616
  proc: proc.subscribe_event(
544
- "last_error", tango.EventType.DATA_READY_EVENT, cbk
617
+ "last_error", tango.EventType.DATA_READY_EVENT, on_error
545
618
  )
546
619
  for proc in self._devs
547
620
  }
@@ -33,7 +33,6 @@ def get_proc_params(pipeline: Literal["Legacy", "Smx", "Xpcs"]) -> dict:
33
33
 
34
34
  proc_class = pipelines.get_class(f"LimaProcessing{pipeline}")
35
35
  proc_params: dict = proc_class.params_default
36
- proc_params["class_name"] = proc_class.tango_class
37
36
 
38
37
  return proc_params
39
38
 
@@ -64,6 +63,7 @@ def run_acquisition(
64
63
 
65
64
  def main():
66
65
  import copy
66
+ import logging
67
67
  import os
68
68
 
69
69
  import tango as tg
@@ -83,6 +83,8 @@ def main():
83
83
  if not os.getenv("TANGO_HOST"):
84
84
  raise ValueError("TANGO_HOST must be exported")
85
85
 
86
+ logging.basicConfig(level=logging.INFO)
87
+
86
88
  #############
87
89
  # Populate user namespace
88
90
 
@@ -103,7 +105,7 @@ def main():
103
105
  proc_params = get_proc_params("Legacy")
104
106
  proc_params["saving"]["file_exists_policy"] = "overwrite"
105
107
 
106
- ctl_params = c.params_default[c.detector.ctrl.name()]["acq_params"]
108
+ ctl_params = c.params_default[c.control.name()]["acq_params"]
107
109
 
108
110
  rcv_params = copy.deepcopy(ctl_params)
109
111