blissdata-lima2 2.0.1__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.
- blissdata_lima2-2.0.1/LICENSE.md +7 -0
- blissdata_lima2-2.0.1/PKG-INFO +27 -0
- blissdata_lima2-2.0.1/README.md +3 -0
- blissdata_lima2-2.0.1/pyproject.toml +28 -0
- blissdata_lima2-2.0.1/setup.cfg +4 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2/__init__.py +4 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2/stream.py +349 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2/tests/__init__.py +0 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2/tests/conftest.py +1 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2/tests/test_lima2_stream.py +448 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2.egg-info/PKG-INFO +27 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2.egg-info/SOURCES.txt +14 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2.egg-info/dependency_links.txt +1 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2.egg-info/entry_points.txt +2 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2.egg-info/requires.txt +10 -0
- blissdata_lima2-2.0.1/src/blissdata_lima2.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
Copyright (c) 2020-2024 Beamline Control Unit, ESRF
|
|
2
|
+
|
|
3
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
4
|
+
|
|
5
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
|
|
6
|
+
|
|
7
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blissdata-lima2
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Summary: Lima2 plugin for blissdata
|
|
5
|
+
Maintainer: BCU (ESRF)
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://gitlab.esrf.fr/bliss/blissdata-lima2
|
|
8
|
+
Keywords: blissdata,lima2
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE.md
|
|
14
|
+
Requires-Dist: blissdata~=2.3
|
|
15
|
+
Requires-Dist: lima2-client~=4.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: black; extra == "dev"
|
|
18
|
+
Requires-Dist: flake8; extra == "dev"
|
|
19
|
+
Requires-Dist: mypy; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-redis; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
## blissdata-lima2
|
|
26
|
+
|
|
27
|
+
Lima2 plugin for blissdata.
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "blissdata-lima2"
|
|
7
|
+
version = "2.0.1"
|
|
8
|
+
description = "Lima2 plugin for blissdata"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
license = "MIT"
|
|
11
|
+
license-files = ["LICENSE.md"]
|
|
12
|
+
maintainers = [{ name = "BCU (ESRF)" }]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Intended Audience :: Science/Research",
|
|
15
|
+
"Programming Language :: Python :: 3",
|
|
16
|
+
]
|
|
17
|
+
keywords = ["blissdata", "lima2"]
|
|
18
|
+
requires-python = ">=3.10"
|
|
19
|
+
dependencies = ["blissdata~=2.3", "lima2-client~=4.0"]
|
|
20
|
+
|
|
21
|
+
[project.urls]
|
|
22
|
+
Repository = "https://gitlab.esrf.fr/bliss/blissdata-lima2"
|
|
23
|
+
|
|
24
|
+
[project.optional-dependencies]
|
|
25
|
+
dev = ["black", "flake8", "mypy", "pytest", "pytest-cov", "pytest-redis"]
|
|
26
|
+
|
|
27
|
+
[project.entry-points.blissdata]
|
|
28
|
+
lima2 = "blissdata_lima2"
|
|
@@ -0,0 +1,349 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# This file is part of the bliss project
|
|
4
|
+
#
|
|
5
|
+
# Copyright (c) Beamline Control Unit, ESRF
|
|
6
|
+
# Distributed under the GNU LGPLv3. See LICENSE for more info.
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
from collections.abc import Sequence
|
|
10
|
+
|
|
11
|
+
import lima2.client.services as l2s
|
|
12
|
+
import numpy as np
|
|
13
|
+
from blissdata.exceptions import (
|
|
14
|
+
EmptyViewException,
|
|
15
|
+
EndOfStream,
|
|
16
|
+
IndexNoMoreThereError,
|
|
17
|
+
IndexNotYetThereError,
|
|
18
|
+
IndexWontBeThereError,
|
|
19
|
+
)
|
|
20
|
+
from blissdata.h5api import dynamic_hdf5
|
|
21
|
+
from blissdata.lima.image_utils import ImageData
|
|
22
|
+
from blissdata.streams import (
|
|
23
|
+
BaseStream,
|
|
24
|
+
BaseView,
|
|
25
|
+
EventRange,
|
|
26
|
+
StreamDefinition,
|
|
27
|
+
)
|
|
28
|
+
from blissdata.streams.default import Stream
|
|
29
|
+
from blissdata.streams.encoding.numeric import NumericStreamEncoder
|
|
30
|
+
from blissdata.streams.event_stream import EventStream
|
|
31
|
+
from blissdata.streams.lima.stream import LimaDirectAccess
|
|
32
|
+
from lima2.common.devencoded.sparse_frame import SparseFrame
|
|
33
|
+
from numpy.typing import DTypeLike
|
|
34
|
+
|
|
35
|
+
_logger = logging.getLogger(__name__)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def get_frame(
|
|
39
|
+
services: l2s.ConductorServices,
|
|
40
|
+
acq_uuid: str,
|
|
41
|
+
source: str,
|
|
42
|
+
frame_idx: int,
|
|
43
|
+
) -> ImageData:
|
|
44
|
+
frm = services.pipeline.get_frame(frame_idx=frame_idx, source=source, uuid=acq_uuid)
|
|
45
|
+
|
|
46
|
+
if isinstance(frm, SparseFrame):
|
|
47
|
+
frm = frm.densify()
|
|
48
|
+
|
|
49
|
+
return ImageData(array=frm.data, frame_id=frm.idx, acq_tag=None)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class Lima2View(BaseView):
|
|
53
|
+
def __init__(
|
|
54
|
+
self,
|
|
55
|
+
services: l2s.ConductorServices,
|
|
56
|
+
acq_uuid: str,
|
|
57
|
+
source: str,
|
|
58
|
+
start: int,
|
|
59
|
+
stop: int,
|
|
60
|
+
) -> None:
|
|
61
|
+
self._services = services
|
|
62
|
+
"""Lima2 client services."""
|
|
63
|
+
self._acq_uuid = acq_uuid
|
|
64
|
+
"""Lima2 acquisition id."""
|
|
65
|
+
self._source = source
|
|
66
|
+
"""Frame source name."""
|
|
67
|
+
self._idx_range = range(start, stop)
|
|
68
|
+
"""Range of absolute frame indices accessible via this view."""
|
|
69
|
+
|
|
70
|
+
@property
|
|
71
|
+
def index(self) -> int:
|
|
72
|
+
return self._idx_range.start
|
|
73
|
+
|
|
74
|
+
def __len__(self) -> int:
|
|
75
|
+
return len(self._idx_range)
|
|
76
|
+
|
|
77
|
+
def get_data(self, start: int | None = None, stop: int | None = None) -> np.ndarray:
|
|
78
|
+
frames = []
|
|
79
|
+
for idx in self._idx_range[start:stop]:
|
|
80
|
+
try:
|
|
81
|
+
frames.append(
|
|
82
|
+
get_frame(
|
|
83
|
+
services=self._services,
|
|
84
|
+
acq_uuid=self._acq_uuid,
|
|
85
|
+
source=self._source,
|
|
86
|
+
frame_idx=idx,
|
|
87
|
+
).array
|
|
88
|
+
)
|
|
89
|
+
except RuntimeError as e:
|
|
90
|
+
# Raise if any frame can't be accessed
|
|
91
|
+
raise IndexNoMoreThereError(
|
|
92
|
+
f"Can't fetch {self._source} {self._idx_range[start:stop]}: {e}"
|
|
93
|
+
) from e
|
|
94
|
+
except ValueError as e:
|
|
95
|
+
# Frame source has no associated memory buffer (raw_frame)
|
|
96
|
+
raise IndexNoMoreThereError(
|
|
97
|
+
f"Can't fetch {self._source}: no associated live data"
|
|
98
|
+
) from e
|
|
99
|
+
|
|
100
|
+
return np.asarray(frames)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
class Lima2Stream(BaseStream, LimaDirectAccess):
|
|
104
|
+
"""Stream of Lima2 frames.
|
|
105
|
+
|
|
106
|
+
Frames don't actually transit inside the stream. The stream length can be
|
|
107
|
+
queried to determine the number of accessible frames.
|
|
108
|
+
|
|
109
|
+
Indexing or slicing the stream attempts to fetch frames directly from the
|
|
110
|
+
Lima2 backend.
|
|
111
|
+
"""
|
|
112
|
+
|
|
113
|
+
PROTOCOL_VERSION = 2
|
|
114
|
+
|
|
115
|
+
def __init__(self, event_stream: EventStream) -> None:
|
|
116
|
+
BaseStream.__init__(self, event_stream)
|
|
117
|
+
|
|
118
|
+
_logger.debug(f"Instantiate Lima2Stream with {event_stream.info=}")
|
|
119
|
+
|
|
120
|
+
if event_stream.info["protocol_version"] != Lima2Stream.PROTOCOL_VERSION:
|
|
121
|
+
raise RuntimeError(
|
|
122
|
+
f"Lima2 protocol version mismatch "
|
|
123
|
+
f"(expected {Lima2Stream.PROTOCOL_VERSION}, "
|
|
124
|
+
f"got {event_stream.info['protocol_version']})"
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
self._dtype = np.dtype(event_stream.info["dtype"])
|
|
128
|
+
self._shape = tuple(event_stream.info["shape"])
|
|
129
|
+
self._acq_uuid = str(event_stream.info["acq_uuid"])
|
|
130
|
+
self._source = str(event_stream.info["source_name"])
|
|
131
|
+
self._master_file: tuple[str, str] | None = event_stream.info["master_file"]
|
|
132
|
+
|
|
133
|
+
self._services = l2s.init(
|
|
134
|
+
hostname=str(event_stream.info["conductor_hostname"]),
|
|
135
|
+
port=int(event_stream.info["conductor_port"]),
|
|
136
|
+
)
|
|
137
|
+
"""Lima2 client session."""
|
|
138
|
+
|
|
139
|
+
self._length = 0
|
|
140
|
+
"""Current number of accessible frames."""
|
|
141
|
+
|
|
142
|
+
self._cursor = Stream(event_stream).cursor()
|
|
143
|
+
|
|
144
|
+
####################################################################################
|
|
145
|
+
# BaseStream
|
|
146
|
+
####################################################################################
|
|
147
|
+
|
|
148
|
+
@property
|
|
149
|
+
def kind(self) -> str:
|
|
150
|
+
return "array"
|
|
151
|
+
|
|
152
|
+
@staticmethod
|
|
153
|
+
def make_definition(
|
|
154
|
+
name: str,
|
|
155
|
+
source_name: str,
|
|
156
|
+
conductor_hostname: str,
|
|
157
|
+
conductor_port: int,
|
|
158
|
+
acq_uuid: str,
|
|
159
|
+
master_file: tuple[str, str] | None,
|
|
160
|
+
dtype: DTypeLike,
|
|
161
|
+
shape: Sequence[int],
|
|
162
|
+
) -> StreamDefinition:
|
|
163
|
+
info = {
|
|
164
|
+
"plugin": "lima2",
|
|
165
|
+
"dtype": np.dtype(dtype).name,
|
|
166
|
+
"shape": shape,
|
|
167
|
+
"protocol_version": Lima2Stream.PROTOCOL_VERSION,
|
|
168
|
+
"acq_uuid": acq_uuid,
|
|
169
|
+
"source_name": source_name,
|
|
170
|
+
"conductor_hostname": conductor_hostname,
|
|
171
|
+
"conductor_port": conductor_port,
|
|
172
|
+
"master_file": master_file,
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
return StreamDefinition(name, info, NumericStreamEncoder(np.uint32))
|
|
176
|
+
|
|
177
|
+
@property
|
|
178
|
+
def plugin(self) -> str:
|
|
179
|
+
return "lima2"
|
|
180
|
+
|
|
181
|
+
@property
|
|
182
|
+
def dtype(self) -> np.dtype:
|
|
183
|
+
return self._dtype
|
|
184
|
+
|
|
185
|
+
@property
|
|
186
|
+
def shape(self) -> tuple[int, ...]:
|
|
187
|
+
return self._shape
|
|
188
|
+
|
|
189
|
+
def __len__(self) -> int:
|
|
190
|
+
try:
|
|
191
|
+
view = self._cursor.read(block=False, last_only=True)
|
|
192
|
+
except EndOfStream:
|
|
193
|
+
view = None
|
|
194
|
+
|
|
195
|
+
if view is not None:
|
|
196
|
+
last_status = view.get_data()[0]
|
|
197
|
+
self._length = int(last_status)
|
|
198
|
+
return self._length
|
|
199
|
+
|
|
200
|
+
def __getitem__(self, key: int | slice) -> np.ndarray:
|
|
201
|
+
sealed = self.is_sealed()
|
|
202
|
+
if isinstance(key, int):
|
|
203
|
+
size = len(self)
|
|
204
|
+
if key < 0:
|
|
205
|
+
if not sealed:
|
|
206
|
+
raise IndexNotYetThereError(
|
|
207
|
+
"Can't index from end of stream until it is sealed"
|
|
208
|
+
)
|
|
209
|
+
else:
|
|
210
|
+
key += size
|
|
211
|
+
|
|
212
|
+
if not 0 <= key < size:
|
|
213
|
+
# Fail early if we're out of bounds
|
|
214
|
+
if sealed:
|
|
215
|
+
raise IndexWontBeThereError(
|
|
216
|
+
f"Frame {key} is out of range (0..{size-1})"
|
|
217
|
+
)
|
|
218
|
+
else:
|
|
219
|
+
raise IndexNotYetThereError(
|
|
220
|
+
f"Frame {key} is out of range (0..{size-1})"
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
return self._fetch_frames(start=key, stop=key + 1, step=1)[0]
|
|
224
|
+
|
|
225
|
+
elif isinstance(key, slice):
|
|
226
|
+
if (key.start or 0) < 0 or (key.stop or 0) < 0:
|
|
227
|
+
if not sealed:
|
|
228
|
+
raise IndexNotYetThereError(
|
|
229
|
+
"Can't slice from end of stream until it is sealed"
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
# Resolve the slice in the standard way
|
|
233
|
+
start, stop, step = key.indices(len(self))
|
|
234
|
+
|
|
235
|
+
return self._fetch_frames(start=start, stop=stop, step=step)
|
|
236
|
+
|
|
237
|
+
else:
|
|
238
|
+
raise TypeError(f"{type(key)}")
|
|
239
|
+
|
|
240
|
+
def _need_last_only(self, last_only: bool) -> bool:
|
|
241
|
+
# Lima2 event stream represents current progress
|
|
242
|
+
# -> only the latest one is relevant.
|
|
243
|
+
return True
|
|
244
|
+
|
|
245
|
+
def _build_view_from_events(
|
|
246
|
+
self, index: int, events: EventRange, last_only: bool
|
|
247
|
+
) -> Lima2View:
|
|
248
|
+
"""
|
|
249
|
+
Build a Lima2View to access a slice of frames which starts at `index`,
|
|
250
|
+
and ends at the most recent frame according to `events`.
|
|
251
|
+
"""
|
|
252
|
+
_logger.debug(f"{self.name}: {index=} -> {events=}")
|
|
253
|
+
|
|
254
|
+
# events.data[-1] corresponds to the current number of contiguous frames
|
|
255
|
+
# accessible from the lima2 backend.
|
|
256
|
+
stop_idx = events.data[-1]
|
|
257
|
+
|
|
258
|
+
if stop_idx <= index:
|
|
259
|
+
# no new image despite new events
|
|
260
|
+
raise EmptyViewException
|
|
261
|
+
|
|
262
|
+
return Lima2View(
|
|
263
|
+
services=self._services,
|
|
264
|
+
acq_uuid=self._acq_uuid,
|
|
265
|
+
source=self._source,
|
|
266
|
+
start=stop_idx - 1 if last_only else index,
|
|
267
|
+
stop=stop_idx,
|
|
268
|
+
)
|
|
269
|
+
|
|
270
|
+
####################################################################################
|
|
271
|
+
# LimaDirectAccess
|
|
272
|
+
####################################################################################
|
|
273
|
+
|
|
274
|
+
def get_last_live_image(self) -> ImageData:
|
|
275
|
+
return get_frame(
|
|
276
|
+
services=self._services,
|
|
277
|
+
acq_uuid=self._acq_uuid,
|
|
278
|
+
source=self._source,
|
|
279
|
+
frame_idx=-1,
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
####################################################################################
|
|
283
|
+
# Private API
|
|
284
|
+
####################################################################################
|
|
285
|
+
|
|
286
|
+
def _fetch_frames(self, start: int, stop: int, step: int) -> np.ndarray:
|
|
287
|
+
"""Get frame data online or offline, depending on the current state."""
|
|
288
|
+
if self.is_sealed():
|
|
289
|
+
if self._master_file is not None:
|
|
290
|
+
# Default to an offline lookup
|
|
291
|
+
filepath, datapath = self._master_file
|
|
292
|
+
return self._fetch_from_disk(
|
|
293
|
+
filepath=filepath,
|
|
294
|
+
datapath=datapath,
|
|
295
|
+
start=start,
|
|
296
|
+
stop=stop,
|
|
297
|
+
step=step,
|
|
298
|
+
)
|
|
299
|
+
else:
|
|
300
|
+
# Try online, in case backends still have the frames we want
|
|
301
|
+
_logger.warning(
|
|
302
|
+
f"Requesting frames {start}:{stop}:{step} after stream is sealed, "
|
|
303
|
+
f"but this frame source ({self._source}) isn't persistent."
|
|
304
|
+
)
|
|
305
|
+
return self._fetch_online(start=start, stop=stop, step=step)
|
|
306
|
+
|
|
307
|
+
else:
|
|
308
|
+
return self._fetch_online(start=start, stop=stop, step=step)
|
|
309
|
+
|
|
310
|
+
def _fetch_online(self, start: int, stop: int, step: int) -> np.ndarray:
|
|
311
|
+
"""Get frame data directly from the Lima2 devices."""
|
|
312
|
+
_logger.info(
|
|
313
|
+
f"Fetching {self._source} {start}:{stop}:{step} from lima2 backend"
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
frames = []
|
|
317
|
+
for idx in range(start, stop, step):
|
|
318
|
+
try:
|
|
319
|
+
frames.append(
|
|
320
|
+
get_frame(
|
|
321
|
+
services=self._services,
|
|
322
|
+
acq_uuid=self._acq_uuid,
|
|
323
|
+
source=self._source,
|
|
324
|
+
frame_idx=idx,
|
|
325
|
+
).array
|
|
326
|
+
)
|
|
327
|
+
except RuntimeError as e:
|
|
328
|
+
# Raise if any frame can't be accessed
|
|
329
|
+
raise IndexNoMoreThereError(
|
|
330
|
+
f"Can't fetch {self._source} {range(start, stop, step)}: {e}"
|
|
331
|
+
) from e
|
|
332
|
+
except ValueError as e:
|
|
333
|
+
# Frame source has no associated memory buffer (raw_frame)
|
|
334
|
+
raise IndexNoMoreThereError(
|
|
335
|
+
f"Can't fetch {self._source}: no associated live data"
|
|
336
|
+
) from e
|
|
337
|
+
|
|
338
|
+
return np.asarray(frames)
|
|
339
|
+
|
|
340
|
+
def _fetch_from_disk(
|
|
341
|
+
self, filepath: str, datapath: str, start: int, stop: int, step: int
|
|
342
|
+
) -> np.ndarray:
|
|
343
|
+
"""Get frame data from a file (i.e. "offline")."""
|
|
344
|
+
_logger.info(
|
|
345
|
+
f"Fetching {self._source} {start}:{stop}:{step} from disk via {filepath}"
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
with dynamic_hdf5.File(filepath, retry_timeout=10, retry_period=1) as file:
|
|
349
|
+
return file[datapath][start:stop:step]
|
|
File without changes
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
from blissdata.tests.conftest import redis_db, redis_url, data_store # noqa F401
|
|
@@ -0,0 +1,448 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
#
|
|
3
|
+
# This file is part of the bliss project
|
|
4
|
+
#
|
|
5
|
+
# Copyright (c) Beamline Control Unit, ESRF
|
|
6
|
+
# Distributed under the GNU LGPLv3. See LICENSE for more info.
|
|
7
|
+
|
|
8
|
+
"""Unit test suite for Lima2 stream and view (streams/lima2.py)."""
|
|
9
|
+
|
|
10
|
+
from types import SimpleNamespace
|
|
11
|
+
from unittest.mock import MagicMock, Mock, call
|
|
12
|
+
from uuid import uuid1
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
import pytest
|
|
16
|
+
from blissdata.exceptions import (
|
|
17
|
+
EmptyViewException,
|
|
18
|
+
IndexNoMoreThereError,
|
|
19
|
+
IndexNotYetThereError,
|
|
20
|
+
IndexWontBeThereError,
|
|
21
|
+
)
|
|
22
|
+
from blissdata.lima.image_utils import ImageData
|
|
23
|
+
from blissdata.streams import EventRange, EventStream
|
|
24
|
+
from lima2.common.devencoded.sparse_frame import SparseFrame
|
|
25
|
+
|
|
26
|
+
from blissdata_lima2 import Lima2Stream, Lima2View
|
|
27
|
+
from blissdata_lima2.stream import get_frame
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_lima2_get_frame():
|
|
31
|
+
services = Mock()
|
|
32
|
+
uuid = str(uuid1())
|
|
33
|
+
frm = get_frame(services=services, acq_uuid=uuid, source="cafe", frame_idx=123)
|
|
34
|
+
services.pipeline.get_frame.assert_called_with(
|
|
35
|
+
frame_idx=123, source="cafe", uuid=uuid
|
|
36
|
+
)
|
|
37
|
+
assert type(frm) is ImageData
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def test_lima2_get_sparse_frame():
|
|
41
|
+
mock_frame = Mock(spec=SparseFrame)
|
|
42
|
+
|
|
43
|
+
def mock_pipeline_get_frame(frame_idx, source, uuid):
|
|
44
|
+
return mock_frame
|
|
45
|
+
|
|
46
|
+
mock_services = SimpleNamespace(
|
|
47
|
+
pipeline=SimpleNamespace(get_frame=mock_pipeline_get_frame)
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
uuid = str(uuid1())
|
|
51
|
+
img_data = get_frame(
|
|
52
|
+
services=mock_services, acq_uuid=uuid, source="cafe", frame_idx=123
|
|
53
|
+
)
|
|
54
|
+
mock_frame.densify.assert_called_once()
|
|
55
|
+
assert type(img_data) is ImageData
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_lima2_view(monkeypatch):
|
|
59
|
+
services = Mock()
|
|
60
|
+
uuid = str(uuid1())
|
|
61
|
+
view = Lima2View(
|
|
62
|
+
services=services,
|
|
63
|
+
acq_uuid=uuid,
|
|
64
|
+
source="cafe",
|
|
65
|
+
start=0,
|
|
66
|
+
stop=42,
|
|
67
|
+
)
|
|
68
|
+
assert len(view) == 42
|
|
69
|
+
assert view.index == 0
|
|
70
|
+
|
|
71
|
+
frames = view.get_data()
|
|
72
|
+
assert len(frames) == len(view)
|
|
73
|
+
|
|
74
|
+
services.pipeline.get_frame.assert_has_calls(
|
|
75
|
+
[call(uuid=uuid, source="cafe", frame_idx=i) for i in range(42)]
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
# RuntimeError
|
|
79
|
+
def mock_get_frame_runtime_error(services, acq_uuid, source, frame_idx):
|
|
80
|
+
raise RuntimeError("frame has left the buffer")
|
|
81
|
+
|
|
82
|
+
monkeypatch.setattr(
|
|
83
|
+
"blissdata_lima2.stream.get_frame", mock_get_frame_runtime_error
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
with pytest.raises(IndexNoMoreThereError):
|
|
87
|
+
frames = view.get_data()
|
|
88
|
+
|
|
89
|
+
# ValueError
|
|
90
|
+
def mock_get_frame_value_error(services, acq_uuid, source, frame_idx):
|
|
91
|
+
raise ValueError("no such frame buffer")
|
|
92
|
+
|
|
93
|
+
monkeypatch.setattr("blissdata_lima2.stream.get_frame", mock_get_frame_value_error)
|
|
94
|
+
|
|
95
|
+
with pytest.raises(IndexNoMoreThereError):
|
|
96
|
+
frames = view.get_data()
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_lima2_protocol(data_store):
|
|
100
|
+
uuid = str(uuid1())
|
|
101
|
+
|
|
102
|
+
stream_def = Lima2Stream.make_definition(
|
|
103
|
+
name="device:cafe",
|
|
104
|
+
source_name="cafe",
|
|
105
|
+
conductor_hostname="www.lima2.org",
|
|
106
|
+
conductor_port=12345,
|
|
107
|
+
acq_uuid=uuid,
|
|
108
|
+
master_file=None,
|
|
109
|
+
dtype=np.float128, # fat pixels >:)
|
|
110
|
+
shape=(4, 1024, 512),
|
|
111
|
+
)
|
|
112
|
+
model = data_store._stream_model(
|
|
113
|
+
encoding=stream_def.encoder.info(), info=stream_def.info
|
|
114
|
+
)
|
|
115
|
+
model.info["protocol_version"] = 1 # hack the protocol number
|
|
116
|
+
event_stream = EventStream.create(data_store, stream_def.name, model)
|
|
117
|
+
|
|
118
|
+
with pytest.raises(RuntimeError):
|
|
119
|
+
_ = Lima2Stream(event_stream=event_stream)
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@pytest.fixture
|
|
123
|
+
def lima2_stream(data_store):
|
|
124
|
+
uuid = str(uuid1())
|
|
125
|
+
|
|
126
|
+
stream_def = Lima2Stream.make_definition(
|
|
127
|
+
name="device:cafe",
|
|
128
|
+
source_name="cafe",
|
|
129
|
+
conductor_hostname="www.lima2.org",
|
|
130
|
+
conductor_port=12345,
|
|
131
|
+
acq_uuid=uuid,
|
|
132
|
+
master_file=None,
|
|
133
|
+
dtype=np.float128, # fat pixels >:)
|
|
134
|
+
shape=(4, 1024, 512),
|
|
135
|
+
)
|
|
136
|
+
model = data_store._stream_model(
|
|
137
|
+
encoding=stream_def.encoder.info(), info=stream_def.info
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
event_stream = EventStream.create(data_store, stream_def.name, model)
|
|
141
|
+
stream = Lima2Stream(event_stream=event_stream)
|
|
142
|
+
|
|
143
|
+
return (stream_def, event_stream, stream)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def test_lima2_stream_attributes(lima2_stream):
|
|
147
|
+
stream_def, event_stream, stream = lima2_stream
|
|
148
|
+
|
|
149
|
+
assert stream.plugin == "lima2"
|
|
150
|
+
assert stream.shape == stream_def.info["shape"]
|
|
151
|
+
assert stream.kind == "array"
|
|
152
|
+
assert stream.dtype == stream._dtype
|
|
153
|
+
assert stream._need_last_only(last_only=True)
|
|
154
|
+
assert stream._need_last_only(last_only=False)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def test_lima2_stream_indexing(lima2_stream, monkeypatch):
|
|
158
|
+
stream_def, event_stream, stream = lima2_stream
|
|
159
|
+
|
|
160
|
+
# Feed the event stream
|
|
161
|
+
event_stream.send(np.uint32(42))
|
|
162
|
+
event_stream.join()
|
|
163
|
+
assert len(stream) == 42
|
|
164
|
+
|
|
165
|
+
event_stream.send(np.uint32(123))
|
|
166
|
+
event_stream.join()
|
|
167
|
+
assert len(stream) == 123
|
|
168
|
+
|
|
169
|
+
mock_get_frame = Mock()
|
|
170
|
+
monkeypatch.setattr("blissdata_lima2.stream.get_frame", mock_get_frame)
|
|
171
|
+
|
|
172
|
+
# Indexing
|
|
173
|
+
_ = stream[0]
|
|
174
|
+
mock_get_frame.assert_called_with(
|
|
175
|
+
services=stream._services,
|
|
176
|
+
acq_uuid=stream._acq_uuid,
|
|
177
|
+
source="cafe",
|
|
178
|
+
frame_idx=0,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
with pytest.raises(IndexNotYetThereError):
|
|
182
|
+
_ = stream[-1]
|
|
183
|
+
|
|
184
|
+
with pytest.raises(IndexNotYetThereError):
|
|
185
|
+
_ = stream[123123] # wayyy out of bounds, but we're not sealed yet
|
|
186
|
+
|
|
187
|
+
with monkeypatch.context() as mpc:
|
|
188
|
+
mpc.setattr(stream, "is_sealed", lambda: True)
|
|
189
|
+
|
|
190
|
+
with pytest.raises(IndexWontBeThereError):
|
|
191
|
+
_ = stream[123123] # wayyy out of bounds and we're sealed
|
|
192
|
+
|
|
193
|
+
event_stream.seal()
|
|
194
|
+
event_stream.join()
|
|
195
|
+
|
|
196
|
+
_ = stream[-3]
|
|
197
|
+
assert mock_get_frame.mock_calls[-1] == call(
|
|
198
|
+
services=stream._services,
|
|
199
|
+
acq_uuid=stream._acq_uuid,
|
|
200
|
+
source="cafe",
|
|
201
|
+
frame_idx=123 - 3,
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def test_lima2_stream_slicing(lima2_stream, monkeypatch):
|
|
206
|
+
stream_def, event_stream, stream = lima2_stream
|
|
207
|
+
|
|
208
|
+
# Feed the event stream
|
|
209
|
+
event_stream.send(np.uint32(42))
|
|
210
|
+
event_stream.join()
|
|
211
|
+
assert len(stream) == 42
|
|
212
|
+
|
|
213
|
+
event_stream.send(np.uint32(123))
|
|
214
|
+
event_stream.join()
|
|
215
|
+
assert len(stream) == 123
|
|
216
|
+
|
|
217
|
+
mock_get_frame = Mock()
|
|
218
|
+
monkeypatch.setattr("blissdata_lima2.stream.get_frame", mock_get_frame)
|
|
219
|
+
|
|
220
|
+
# Slicing
|
|
221
|
+
_ = stream[:3]
|
|
222
|
+
assert mock_get_frame.mock_calls[-3:] == [
|
|
223
|
+
call(
|
|
224
|
+
services=stream._services,
|
|
225
|
+
acq_uuid=stream._acq_uuid,
|
|
226
|
+
source="cafe",
|
|
227
|
+
frame_idx=0,
|
|
228
|
+
),
|
|
229
|
+
call(
|
|
230
|
+
services=stream._services,
|
|
231
|
+
acq_uuid=stream._acq_uuid,
|
|
232
|
+
source="cafe",
|
|
233
|
+
frame_idx=1,
|
|
234
|
+
),
|
|
235
|
+
call(
|
|
236
|
+
services=stream._services,
|
|
237
|
+
acq_uuid=stream._acq_uuid,
|
|
238
|
+
source="cafe",
|
|
239
|
+
frame_idx=2,
|
|
240
|
+
),
|
|
241
|
+
]
|
|
242
|
+
|
|
243
|
+
with pytest.raises(IndexNotYetThereError):
|
|
244
|
+
_ = stream[-2:]
|
|
245
|
+
|
|
246
|
+
with pytest.raises(IndexNotYetThereError):
|
|
247
|
+
_ = stream[:-2]
|
|
248
|
+
|
|
249
|
+
with pytest.raises(TypeError):
|
|
250
|
+
_ = stream["hi :)"]
|
|
251
|
+
|
|
252
|
+
event_stream.seal()
|
|
253
|
+
event_stream.join()
|
|
254
|
+
|
|
255
|
+
# Now slicing/indexing from end is ok
|
|
256
|
+
_ = stream[-2:]
|
|
257
|
+
assert mock_get_frame.mock_calls[-2:] == [
|
|
258
|
+
call(
|
|
259
|
+
services=stream._services,
|
|
260
|
+
acq_uuid=stream._acq_uuid,
|
|
261
|
+
source="cafe",
|
|
262
|
+
frame_idx=123 - 2,
|
|
263
|
+
),
|
|
264
|
+
call(
|
|
265
|
+
services=stream._services,
|
|
266
|
+
acq_uuid=stream._acq_uuid,
|
|
267
|
+
source="cafe",
|
|
268
|
+
frame_idx=123 - 1,
|
|
269
|
+
),
|
|
270
|
+
]
|
|
271
|
+
|
|
272
|
+
_ = stream[:]
|
|
273
|
+
assert mock_get_frame.mock_calls[-123:] == [
|
|
274
|
+
call(
|
|
275
|
+
services=stream._services,
|
|
276
|
+
acq_uuid=stream._acq_uuid,
|
|
277
|
+
source="cafe",
|
|
278
|
+
frame_idx=i,
|
|
279
|
+
)
|
|
280
|
+
for i in range(123)
|
|
281
|
+
]
|
|
282
|
+
|
|
283
|
+
_ = stream.get_last_live_image()
|
|
284
|
+
mock_get_frame.assert_called_with(
|
|
285
|
+
services=stream._services,
|
|
286
|
+
acq_uuid=stream._acq_uuid,
|
|
287
|
+
source="cafe",
|
|
288
|
+
frame_idx=-1,
|
|
289
|
+
)
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def test_lima2_stream_build_view(lima2_stream, monkeypatch):
|
|
293
|
+
stream_def, event_stream, stream = lima2_stream
|
|
294
|
+
|
|
295
|
+
mock_get_frame = Mock()
|
|
296
|
+
monkeypatch.setattr("blissdata_lima2.stream.get_frame", mock_get_frame)
|
|
297
|
+
|
|
298
|
+
view = stream._build_view_from_events(
|
|
299
|
+
index=0,
|
|
300
|
+
events=EventRange(
|
|
301
|
+
index=0,
|
|
302
|
+
nb_expired=0,
|
|
303
|
+
data=[np.uint32(5), np.uint32(24), np.uint32(42)],
|
|
304
|
+
end_of_stream=False,
|
|
305
|
+
),
|
|
306
|
+
last_only=False,
|
|
307
|
+
)
|
|
308
|
+
assert view._idx_range == range(0, 42)
|
|
309
|
+
|
|
310
|
+
view = stream._build_view_from_events(
|
|
311
|
+
index=0,
|
|
312
|
+
events=EventRange(
|
|
313
|
+
index=0,
|
|
314
|
+
nb_expired=0,
|
|
315
|
+
data=[np.uint32(5), np.uint32(24), np.uint32(42)],
|
|
316
|
+
end_of_stream=False,
|
|
317
|
+
),
|
|
318
|
+
last_only=True,
|
|
319
|
+
)
|
|
320
|
+
assert view._idx_range == range(41, 42)
|
|
321
|
+
|
|
322
|
+
with pytest.raises(EmptyViewException):
|
|
323
|
+
# index >= data[-1]
|
|
324
|
+
view = stream._build_view_from_events(
|
|
325
|
+
index=42,
|
|
326
|
+
events=EventRange(
|
|
327
|
+
index=0,
|
|
328
|
+
nb_expired=0,
|
|
329
|
+
data=[np.uint32(5), np.uint32(24), np.uint32(42)],
|
|
330
|
+
end_of_stream=False,
|
|
331
|
+
),
|
|
332
|
+
last_only=False,
|
|
333
|
+
)
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_lima2_stream_fetch_frames(lima2_stream, monkeypatch):
|
|
337
|
+
stream_def, event_stream, stream = lima2_stream
|
|
338
|
+
|
|
339
|
+
mock_get_frame = Mock()
|
|
340
|
+
monkeypatch.setattr("blissdata_lima2.stream.get_frame", mock_get_frame)
|
|
341
|
+
|
|
342
|
+
with monkeypatch.context() as mpc:
|
|
343
|
+
# Stream sealed, has master file
|
|
344
|
+
mock_fetch = Mock()
|
|
345
|
+
mpc.setattr(stream, "_fetch_from_disk", mock_fetch)
|
|
346
|
+
mpc.setattr(stream, "is_sealed", lambda: True)
|
|
347
|
+
mpc.setattr(
|
|
348
|
+
stream,
|
|
349
|
+
"_master_file",
|
|
350
|
+
("/path/to/master.h5", "entry/instrument/detector/dataset"),
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
stream._fetch_frames(start=0, stop=42, step=3)
|
|
354
|
+
|
|
355
|
+
mock_fetch.assert_called_once_with(
|
|
356
|
+
filepath="/path/to/master.h5",
|
|
357
|
+
datapath="entry/instrument/detector/dataset",
|
|
358
|
+
start=0,
|
|
359
|
+
stop=42,
|
|
360
|
+
step=3,
|
|
361
|
+
)
|
|
362
|
+
|
|
363
|
+
with monkeypatch.context() as mpc:
|
|
364
|
+
# Stream sealed, no master file
|
|
365
|
+
mock_fetch = Mock()
|
|
366
|
+
mpc.setattr(stream, "_fetch_online", mock_fetch)
|
|
367
|
+
mpc.setattr(stream, "is_sealed", lambda: True)
|
|
368
|
+
mpc.setattr(stream, "_master_file", None)
|
|
369
|
+
|
|
370
|
+
stream._fetch_frames(start=0, stop=42, step=3)
|
|
371
|
+
|
|
372
|
+
mock_fetch.assert_called_once_with(
|
|
373
|
+
start=0,
|
|
374
|
+
stop=42,
|
|
375
|
+
step=3,
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
with monkeypatch.context() as mpc:
|
|
379
|
+
# Stream not sealed
|
|
380
|
+
mock_fetch = Mock()
|
|
381
|
+
mpc.setattr(stream, "_fetch_online", mock_fetch)
|
|
382
|
+
mpc.setattr(stream, "is_sealed", lambda: False)
|
|
383
|
+
|
|
384
|
+
stream._fetch_frames(start=0, stop=42, step=3)
|
|
385
|
+
|
|
386
|
+
mock_fetch.assert_called_once_with(
|
|
387
|
+
start=0,
|
|
388
|
+
stop=42,
|
|
389
|
+
step=3,
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
def test_lima2_stream_fetch_online(lima2_stream, monkeypatch):
|
|
394
|
+
stream_def, event_stream, stream = lima2_stream
|
|
395
|
+
|
|
396
|
+
mock_get_frame = Mock()
|
|
397
|
+
monkeypatch.setattr("blissdata_lima2.stream.get_frame", mock_get_frame)
|
|
398
|
+
|
|
399
|
+
stream._fetch_online(start=0, stop=42, step=3)
|
|
400
|
+
|
|
401
|
+
mock_get_frame.assert_has_calls(
|
|
402
|
+
[
|
|
403
|
+
call(
|
|
404
|
+
services=stream._services,
|
|
405
|
+
acq_uuid=stream._acq_uuid,
|
|
406
|
+
source=stream._source,
|
|
407
|
+
frame_idx=idx,
|
|
408
|
+
)
|
|
409
|
+
for idx in range(0, 42, 3)
|
|
410
|
+
]
|
|
411
|
+
)
|
|
412
|
+
|
|
413
|
+
# RuntimeError
|
|
414
|
+
def mock_get_frame_runtime_error(services, acq_uuid, source, frame_idx):
|
|
415
|
+
raise RuntimeError("oh no :(")
|
|
416
|
+
|
|
417
|
+
monkeypatch.setattr(
|
|
418
|
+
"blissdata_lima2.stream.get_frame", mock_get_frame_runtime_error
|
|
419
|
+
)
|
|
420
|
+
|
|
421
|
+
with pytest.raises(IndexNoMoreThereError):
|
|
422
|
+
_ = stream._fetch_online(start=0, stop=1, step=1)
|
|
423
|
+
|
|
424
|
+
# ValueError
|
|
425
|
+
def mock_get_frame_value_error(services, acq_uuid, source, frame_idx):
|
|
426
|
+
raise ValueError("oh no :(")
|
|
427
|
+
|
|
428
|
+
monkeypatch.setattr("blissdata_lima2.stream.get_frame", mock_get_frame_value_error)
|
|
429
|
+
|
|
430
|
+
with pytest.raises(IndexNoMoreThereError):
|
|
431
|
+
_ = stream._fetch_online(start=0, stop=1, step=1)
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def test_lima2_stream_fetch_offline(lima2_stream, monkeypatch):
|
|
435
|
+
stream_def, event_stream, stream = lima2_stream
|
|
436
|
+
|
|
437
|
+
mock_dynamic_hdf5 = MagicMock()
|
|
438
|
+
monkeypatch.setattr("blissdata_lima2.stream.dynamic_hdf5", mock_dynamic_hdf5)
|
|
439
|
+
|
|
440
|
+
stream._fetch_from_disk(
|
|
441
|
+
filepath="/path/to/master.h5",
|
|
442
|
+
datapath="entry/instrument/detector/dataset",
|
|
443
|
+
start=0,
|
|
444
|
+
stop=42,
|
|
445
|
+
step=3,
|
|
446
|
+
)
|
|
447
|
+
|
|
448
|
+
mock_dynamic_hdf5.File.assert_called_once()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: blissdata-lima2
|
|
3
|
+
Version: 2.0.1
|
|
4
|
+
Summary: Lima2 plugin for blissdata
|
|
5
|
+
Maintainer: BCU (ESRF)
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Repository, https://gitlab.esrf.fr/bliss/blissdata-lima2
|
|
8
|
+
Keywords: blissdata,lima2
|
|
9
|
+
Classifier: Intended Audience :: Science/Research
|
|
10
|
+
Classifier: Programming Language :: Python :: 3
|
|
11
|
+
Requires-Python: >=3.10
|
|
12
|
+
Description-Content-Type: text/markdown
|
|
13
|
+
License-File: LICENSE.md
|
|
14
|
+
Requires-Dist: blissdata~=2.3
|
|
15
|
+
Requires-Dist: lima2-client~=4.0
|
|
16
|
+
Provides-Extra: dev
|
|
17
|
+
Requires-Dist: black; extra == "dev"
|
|
18
|
+
Requires-Dist: flake8; extra == "dev"
|
|
19
|
+
Requires-Dist: mypy; extra == "dev"
|
|
20
|
+
Requires-Dist: pytest; extra == "dev"
|
|
21
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
22
|
+
Requires-Dist: pytest-redis; extra == "dev"
|
|
23
|
+
Dynamic: license-file
|
|
24
|
+
|
|
25
|
+
## blissdata-lima2
|
|
26
|
+
|
|
27
|
+
Lima2 plugin for blissdata.
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
LICENSE.md
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/blissdata_lima2/__init__.py
|
|
5
|
+
src/blissdata_lima2/stream.py
|
|
6
|
+
src/blissdata_lima2.egg-info/PKG-INFO
|
|
7
|
+
src/blissdata_lima2.egg-info/SOURCES.txt
|
|
8
|
+
src/blissdata_lima2.egg-info/dependency_links.txt
|
|
9
|
+
src/blissdata_lima2.egg-info/entry_points.txt
|
|
10
|
+
src/blissdata_lima2.egg-info/requires.txt
|
|
11
|
+
src/blissdata_lima2.egg-info/top_level.txt
|
|
12
|
+
src/blissdata_lima2/tests/__init__.py
|
|
13
|
+
src/blissdata_lima2/tests/conftest.py
|
|
14
|
+
src/blissdata_lima2/tests/test_lima2_stream.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
blissdata_lima2
|