ewoksdata 2.1.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.
Files changed (34) hide show
  1. ewoksdata/__init__.py +0 -0
  2. ewoksdata/data/__init__.py +0 -0
  3. ewoksdata/data/bliss.py +341 -0
  4. ewoksdata/data/blissdata/__init__.py +31 -0
  5. ewoksdata/data/blissdata/blissdatav0.py +119 -0
  6. ewoksdata/data/blissdata/blissdatav1.py +279 -0
  7. ewoksdata/data/blissdata/blissdatav2.py +342 -0
  8. ewoksdata/data/contextiterator.py +43 -0
  9. ewoksdata/data/hdf5/__init__.py +23 -0
  10. ewoksdata/data/hdf5/config.py +186 -0
  11. ewoksdata/data/hdf5/dataset_writer/__init__.py +4 -0
  12. ewoksdata/data/hdf5/dataset_writer/_base.py +55 -0
  13. ewoksdata/data/hdf5/dataset_writer/_non_sequential.py +139 -0
  14. ewoksdata/data/hdf5/dataset_writer/_non_sequential_stack.py +158 -0
  15. ewoksdata/data/hdf5/dataset_writer/_sequential.py +143 -0
  16. ewoksdata/data/hdf5/dataset_writer/_sequential_stack.py +195 -0
  17. ewoksdata/data/hdf5/types.py +14 -0
  18. ewoksdata/data/nexus.py +187 -0
  19. ewoksdata/data/url.py +30 -0
  20. ewoksdata/data/utils.py +53 -0
  21. ewoksdata/tests/__init__.py +0 -0
  22. ewoksdata/tests/conftest.py +30 -0
  23. ewoksdata/tests/data/__init__.py +1 -0
  24. ewoksdata/tests/data/bliss_scans.py +137 -0
  25. ewoksdata/tests/test_contextiterator.py +61 -0
  26. ewoksdata/tests/test_data_bliss.py +98 -0
  27. ewoksdata/tests/test_data_hdf5.py +14 -0
  28. ewoksdata/tests/test_data_nexus.py +80 -0
  29. ewoksdata/tests/test_dataset_writer.py +275 -0
  30. ewoksdata-2.1.0.dist-info/METADATA +70 -0
  31. ewoksdata-2.1.0.dist-info/RECORD +34 -0
  32. ewoksdata-2.1.0.dist-info/WHEEL +5 -0
  33. ewoksdata-2.1.0.dist-info/licenses/LICENSE.md +20 -0
  34. ewoksdata-2.1.0.dist-info/top_level.txt +1 -0
@@ -0,0 +1,195 @@
1
+ import time
2
+ from typing import List
3
+ from typing import Optional
4
+
5
+ import h5py
6
+ import numpy
7
+ from numpy.typing import ArrayLike
8
+
9
+ from ..config import guess_dataset_config
10
+ from ..types import StrictPositiveIntegral
11
+ from ._base import DatasetWriterBase
12
+
13
+
14
+ class StackDatasetWriter(DatasetWriterBase):
15
+ """Append arrays of the same shape to each item of a new HDF5 dataset
16
+ in a sequential manner per item. So each item of the HDF5 dataset is a
17
+ stack to which we can append data in a sequential manner.
18
+
19
+ Instead of creating a dataset with the :code:`h5py` API
20
+
21
+ .. code-block::python
22
+
23
+ stack0 = [[1,2,3], [4,5,6], [7,8,9]]
24
+ stack1 = [[10,11,12], [13,14,15], [16,17,18]]
25
+ h5group["mydataset"] = [stack0, stack1]
26
+
27
+ it can be done like this
28
+
29
+ .. code-block::python
30
+
31
+ with StackDatasetWriter(h5group, "mydataset") as writer:
32
+ writer.add_point([1,2,3], 0)
33
+ writer.add_point([10,11,12], 1)
34
+ writer.add_points([[13,14,15], [16,17,18]], 1)
35
+ writer.add_points([[4,5,6], [7,8,9]], 0)
36
+
37
+ Chunk size determination, chunk-aligned writing, compression and flushing is handled.
38
+ """
39
+
40
+ def __init__(
41
+ self,
42
+ parent: h5py.Group,
43
+ name: str,
44
+ npoints: Optional[StrictPositiveIntegral] = None,
45
+ nstack: Optional[StrictPositiveIntegral] = None,
46
+ attrs: Optional[dict] = None,
47
+ flush_period: Optional[float] = None,
48
+ ) -> None:
49
+ super().__init__(parent, name, attrs=attrs, flush_period=flush_period)
50
+ self._npoints = npoints
51
+ self._chunked: bool = False
52
+ self._nstack = nstack
53
+
54
+ self._buffers: List[List[ArrayLike]] = list()
55
+ self._chunk_size: ArrayLike = numpy.zeros(2, dtype=int)
56
+ self._flushed_size_dim1: ArrayLike = numpy.array(list(), dtype=int)
57
+
58
+ def _create_dataset(
59
+ self, first_data_point: ArrayLike, stack_index: int
60
+ ) -> h5py.Dataset:
61
+ scan_shape = (self._nstack, self._npoints)
62
+ first_data_point = numpy.asarray(first_data_point)
63
+ detector_shape = first_data_point.shape
64
+ dtype = first_data_point.dtype
65
+
66
+ if self._nstack is None or self._npoints is None:
67
+ shape = (stack_index + 1, 1) + detector_shape
68
+ max_shape = scan_shape + detector_shape
69
+ else:
70
+ shape = scan_shape + detector_shape
71
+ max_shape = None
72
+
73
+ options = guess_dataset_config(
74
+ scan_shape, detector_shape, dtype=dtype, max_shape=max_shape
75
+ )
76
+ options.update(
77
+ shape=shape,
78
+ dtype=dtype,
79
+ fillvalue=numpy.nan, # converts to 0 for integers
80
+ )
81
+ if max_shape:
82
+ options["maxshape"] = max_shape
83
+
84
+ if options["chunks"]:
85
+ self._chunked = True
86
+ self._chunk_size = numpy.array(options["chunks"][:2], dtype=int)
87
+
88
+ dset = self._parent.create_dataset(self._name, **options)
89
+ if self._attrs:
90
+ dset.attrs.update(self._attrs)
91
+ return dset
92
+
93
+ def add_point(self, data: ArrayLike, stack_index: int) -> bool:
94
+ """Append one array to one stack of the dataset."""
95
+ if self._dataset is None:
96
+ self._dataset = self._create_dataset(data, stack_index)
97
+
98
+ buffer = self._get_buffer(stack_index)
99
+ buffer.append(data)
100
+ return self.flush_buffer(align=True)
101
+
102
+ def add_points(self, data: ArrayLike, stack_index: int) -> bool:
103
+ """Append several arrays at once to one stack of the dataset."""
104
+ if self._dataset is None:
105
+ self._dataset = self._create_dataset(data[0], stack_index)
106
+
107
+ buffer = self._get_buffer(stack_index)
108
+ buffer.extend(data)
109
+ return self.flush_buffer(align=True)
110
+
111
+ def flush_buffer(self, align: bool = False) -> bool:
112
+ # Determine how many points to flush for each buffer in the stack
113
+ chunk_sizes = numpy.array([len(buffer) for buffer in self._buffers])
114
+ flushed_size_dim1 = self._flushed_size_dim1
115
+ size_dim0 = len(chunk_sizes)
116
+ assert size_dim0 == len(
117
+ flushed_size_dim1
118
+ ), "Number of buffers and number of flushed dim1 points must be the same"
119
+
120
+ chunk_size_dim0, chunk_size_dim1 = self._chunk_size[:2]
121
+ if chunk_size_dim0 == 0:
122
+ chunk_size_dim0 = size_dim0
123
+
124
+ if self._flush_time_expired():
125
+ flush_sizes = chunk_sizes
126
+ elif align and self._chunked:
127
+ size_dim0 = size_dim0 // chunk_size_dim0 * chunk_size_dim0
128
+ chunk_sizes = chunk_sizes[:size_dim0]
129
+ flushed_size_dim1 = flushed_size_dim1[:size_dim0]
130
+ if size_dim0:
131
+ n1 = chunk_sizes + (flushed_size_dim1 % chunk_size_dim1)
132
+ flush_sizes = n1 // chunk_size_dim1 * chunk_size_dim1
133
+ flush_sizes = numpy.minimum(flush_sizes, chunk_sizes)
134
+ for i0_chunk0 in range(0, size_dim0, chunk_size_dim0):
135
+ flush_sizes[i0_chunk0 : i0_chunk0 + chunk_size_dim0] = min(
136
+ flush_sizes[i0_chunk0 : i0_chunk0 + chunk_size_dim0]
137
+ )
138
+ else:
139
+ flush_sizes = list()
140
+ else:
141
+ flush_sizes = chunk_sizes
142
+
143
+ if not any(flush_sizes):
144
+ return False
145
+
146
+ # Enlarge the dataset when needed
147
+ nalloc = self._dataset.shape[:2]
148
+ istart_dim1 = flushed_size_dim1
149
+ flushed_size_dim1 = istart_dim1 + flush_sizes
150
+ nalloc_new = numpy.array([size_dim0, max(flushed_size_dim1)])
151
+ if self._chunked and any(nalloc_new > nalloc):
152
+ for axis, n in enumerate(nalloc_new):
153
+ self._dataset.resize(n, axis=axis)
154
+
155
+ # Copy data from buffer to HDF5
156
+ for i0_chunk0 in range(0, size_dim0, chunk_size_dim0):
157
+ idx_dim0 = slice(i0_chunk0, i0_chunk0 + chunk_size_dim0)
158
+ buffers = self._buffers[idx_dim0]
159
+
160
+ flush_sizes_dim1 = flush_sizes[idx_dim0]
161
+ non_ragged_buffers = len(set(flush_sizes_dim1)) == 1
162
+
163
+ istart0_dim1 = istart_dim1[idx_dim0]
164
+ non_ragged_destination = len(set(istart0_dim1)) == 1
165
+
166
+ if non_ragged_destination and non_ragged_buffers:
167
+ data = [buffer[: flush_sizes_dim1[0]] for buffer in buffers]
168
+ idx_dim1 = slice(istart0_dim1[0], istart0_dim1[0] + flush_sizes_dim1[0])
169
+ self._dataset[idx_dim0, idx_dim1] = data
170
+ else:
171
+ for buffer, i_dim0, istart_dim1, i_flush_size_dim1 in zip(
172
+ buffers,
173
+ range(i0_chunk0, i0_chunk0 + chunk_size_dim0),
174
+ istart0_dim1,
175
+ flush_sizes_dim1,
176
+ ):
177
+ self._dataset[
178
+ i_dim0, istart_dim1 : istart_dim1 + i_flush_size_dim1, ...
179
+ ] = buffer[:i_flush_size_dim1]
180
+
181
+ # Remove copied data from buffer
182
+ for i0 in range(size_dim0):
183
+ self._buffers[i0] = self._buffers[i0][flush_sizes[i0] :]
184
+ self._flushed_size_dim1[i0] = flushed_size_dim1[i0]
185
+
186
+ self._flush_hdf5()
187
+ self._last_flush = time.time()
188
+ return True
189
+
190
+ def _get_buffer(self, stack_index: int) -> List[ArrayLike]:
191
+ # Add stack buffers when needed
192
+ for _ in range(max(stack_index - len(self._buffers) + 1, 0)):
193
+ self._buffers.append(list())
194
+ self._flushed_size_dim1 = numpy.append(self._flushed_size_dim1, 0)
195
+ return self._buffers[stack_index]
@@ -0,0 +1,14 @@
1
+ from numbers import Integral
2
+ from typing import NewType
3
+ from typing import Optional
4
+ from typing import Tuple
5
+
6
+ PositiveIntegral = NewType("PositiveIntegral", Integral) # >= 0
7
+ StrictPositiveIntegral = NewType("StrictPositiveIntegral", Integral) # > 0
8
+ ShapeType = Tuple[StrictPositiveIntegral]
9
+ SizeType = PositiveIntegral
10
+
11
+ VarShapeType = Tuple[Optional[PositiveIntegral]] # 0 or None mark a variable dimension
12
+ VarH5pyShapeType = Tuple[
13
+ Optional[StrictPositiveIntegral]
14
+ ] # None marks a variable dimension
@@ -0,0 +1,187 @@
1
+ import os
2
+ from contextlib import contextmanager
3
+ from typing import Iterator
4
+ from typing import Optional
5
+ from typing import Sequence
6
+ from typing import Tuple
7
+ from typing import Union
8
+
9
+ import h5py
10
+ from silx.io import h5py_utils
11
+
12
+ from .url import DataUrl
13
+ from .url import as_dataurl
14
+
15
+
16
+ def ensure_nxclass(group: h5py.Group) -> None:
17
+ """
18
+ Ensure the HDF5 group has an appropriate ``NX_class`` attribute.
19
+
20
+ The attribute is assigned based on the group's depth in the hierarchy:
21
+ * Root group (``/``): ``NXroot``
22
+ * First-level group: ``NXentry``
23
+ * Deeper groups: ``NXcollection``
24
+
25
+ :param group: The HDF5 group to update.
26
+ :type group: h5py.Group
27
+ """
28
+ if group.attrs.get("NX_class"):
29
+ return
30
+ groups = [s for s in group.name.split("/") if s]
31
+ n = len(groups)
32
+ if n == 0:
33
+ group.attrs["NX_class"] = "NXroot"
34
+ elif n == 1:
35
+ group.attrs["NX_class"] = "NXentry"
36
+ else:
37
+ group.attrs["NX_class"] = "NXcollection"
38
+
39
+
40
+ def select_default_plot(nxdata: h5py.Group) -> None:
41
+ """
42
+ Set the default NXdata group for visualization in the parent hierarchy.
43
+
44
+ The function walks up the group tree and sets the ``default`` attribute
45
+ in each parent to point to the given NXdata group.
46
+
47
+ :param nxdata: An HDF5 group representing an NXdata node.
48
+ :type nxdata: h5py.Group
49
+ """
50
+ parent = nxdata.parent
51
+ for name in nxdata.name.split("/")[::-1]:
52
+ if not name:
53
+ continue
54
+ parent.attrs["default"] = name
55
+ parent = parent.parent
56
+
57
+
58
+ def create_url(url: str, overwrite: bool = False, **open_options) -> DataUrl:
59
+ """
60
+ Create (or ensure the existence of) an HDF5 group specified by a URL.
61
+
62
+ :param url: The HDF5 URL (e.g. ``path/to/file.h5::/entry/data``).
63
+ :param overwrite: If True, overwrite the final group if it exists.
64
+ If False, reuse existing groups. Default is False.
65
+ :param open_options: Additional options passed to ``h5py.File``.
66
+ :return: A DataUrl object pointing to the created or existing group.
67
+ """
68
+ url = as_dataurl(url)
69
+ filename = url.file_path()
70
+ data_path = url.data_path() or "/"
71
+ dirname = os.path.dirname(filename)
72
+ if dirname:
73
+ os.makedirs(dirname, exist_ok=True)
74
+ _ = open_options.setdefault("mode", "a")
75
+ with h5py_utils.open_item(filename, "/", **open_options) as parent:
76
+ h5item, _ = _create_h5group(parent, data_path, overwrite=overwrite)
77
+ return as_dataurl(f"{filename}::{h5item.name}")
78
+
79
+
80
+ def get_nxentry(h5item: Union[h5py.Dataset, h5py.Group]) -> h5py.Group:
81
+ """
82
+ Return the top-level NXentry group containing the given HDF5 item.
83
+
84
+ :param h5item: An HDF5 dataset or group.
85
+ :return: The NXentry group containing ``h5item``.
86
+ :raises ValueError: If the item does not belong to an NXentry hierarchy.
87
+ """
88
+ parts = [s for s in h5item.name.split("/") if s]
89
+ if parts:
90
+ return h5item.file[parts[0]]
91
+ raise ValueError("HDF5 item must be part of an NXentry")
92
+
93
+
94
+ @contextmanager
95
+ def create_nexus_group(
96
+ url: Union[str, DataUrl],
97
+ retry_timeout=None,
98
+ retry_period=None,
99
+ default_levels: Optional[Sequence[str]] = None,
100
+ **open_options,
101
+ ) -> Iterator[Tuple[h5py.Group, bool]]:
102
+ """
103
+ Context manager to create or access an HDF5 group specified by a URL.
104
+
105
+ The data path inside HDF5 is ensured to have at least ``default_levels`` levels.
106
+
107
+ :param url: The HDF5 URL (e.g. ``path/to/file.h5::/entry/data``).
108
+ :param retry_timeout: Maximum time in seconds to retry opening the file.
109
+ :param retry_period: Time in seconds between retries.
110
+ :param default_levels: Minimum path hierarchy touse when missing. Default is ``["/", "results"]``.
111
+ :param open_options: Additional options passed to ``h5py.File``.
112
+ :yields: Tuple of (HDF5 group, already_existed flag).
113
+ """
114
+ url = as_dataurl(url)
115
+ filename = url.file_path()
116
+ itemname = url.data_path() or "/"
117
+ dirname = os.path.dirname(filename)
118
+ if dirname:
119
+ os.makedirs(dirname, exist_ok=True)
120
+ open_options.setdefault("mode", "a")
121
+ with h5py_utils.open_item(
122
+ filename,
123
+ "/",
124
+ retry_timeout=retry_timeout,
125
+ retry_period=retry_period,
126
+ **open_options,
127
+ ) as root:
128
+ yield _create_h5group(root, itemname, default_levels=default_levels)
129
+
130
+
131
+ def _create_h5group(
132
+ parent: h5py.Group,
133
+ data_path: str,
134
+ default_levels: Optional[Sequence[str]] = None,
135
+ overwrite: bool = False,
136
+ ) -> Tuple[h5py.Group, bool]:
137
+ """
138
+ Create or retrieve an HDF5 group under a given parent.
139
+
140
+ Ensures that intermediate groups exist, optionally enforcing a minimum
141
+ depth using ``default_levels``.
142
+
143
+ Missing ``NX_class`` attributes are automatically created on all levels.
144
+ The final group can be replaced if ``overwrite=True``.
145
+
146
+ :param parent: Parent group under which the path should be created.
147
+ :param data_path: Group path to create relative to ``parent``.
148
+ :param default_levels: Minimum hierarchy to enforce.
149
+ If ``data_path`` is shorter, extra levels are added.
150
+ :param overwrite: If True, delete and recreate the final group if it exists.
151
+ Default is False.
152
+ :return: Tuple of (HDF5 group, already_existed flag).
153
+ """
154
+ # Ensure the data path has at least N levels as defined by ``default_levels``
155
+ groups = [s for s in data_path.split("/") if s]
156
+ if default_levels:
157
+ default_levels = list(default_levels)
158
+ else:
159
+ default_levels = ["results"]
160
+ if len(groups) < len(default_levels):
161
+ groups += default_levels[len(groups) :]
162
+
163
+ # Ensure parent groups exist + ensure NX_class
164
+ create = False
165
+ ensure_nxclass(parent)
166
+ for group in groups[:-1]:
167
+ if group in parent:
168
+ parent = parent[group]
169
+ else:
170
+ parent = parent.create_group(group)
171
+ create = True
172
+ ensure_nxclass(parent)
173
+
174
+ # Final group: create, overwrite or confirm existence + ensure NX_class
175
+ group = groups[-1]
176
+ if group in parent:
177
+ if overwrite:
178
+ del parent[group]
179
+ parent = parent.create_group(group)
180
+ else:
181
+ parent = parent[group]
182
+ else:
183
+ parent = parent.create_group(group)
184
+ create = True
185
+ ensure_nxclass(parent)
186
+
187
+ return parent, not create
ewoksdata/data/url.py ADDED
@@ -0,0 +1,30 @@
1
+ import sys
2
+ from typing import Tuple
3
+ from typing import Union
4
+
5
+ from silx.io.url import DataUrl
6
+
7
+
8
+ def as_dataurl(url: Union[str, DataUrl]) -> DataUrl:
9
+ if isinstance(url, str) and sys.platform == "win32":
10
+ url = url.replace("\\", "/")
11
+ if not isinstance(url, DataUrl):
12
+ url = DataUrl(url)
13
+ return url
14
+
15
+
16
+ def as_h5url(url: Union[str, DataUrl]) -> DataUrl:
17
+ url = as_dataurl(url)
18
+ if not url.data_path():
19
+ return DataUrl(f"{url.file_path()}::/")
20
+ return url
21
+
22
+
23
+ def h5dataset_url_parse(url: Union[str, DataUrl]) -> Tuple[str, str, Tuple]:
24
+ url = as_h5url(url)
25
+ filename = str(url.file_path())
26
+ h5path = url.data_path()
27
+ idx = url.data_slice()
28
+ if idx is None:
29
+ idx = tuple()
30
+ return filename, h5path, idx
@@ -0,0 +1,53 @@
1
+ from numbers import Number
2
+ from typing import List
3
+ from typing import Mapping
4
+ from typing import Optional
5
+ from typing import Tuple
6
+
7
+ import numpy
8
+
9
+
10
+ def is_data(data):
11
+ if isinstance(data, (numpy.ndarray, Number)):
12
+ return True
13
+ if isinstance(data, (str, list)) and data:
14
+ return True
15
+ return False
16
+
17
+
18
+ def data_from_storage(data, remove_numpy=True):
19
+ if isinstance(data, numpy.ndarray):
20
+ if not remove_numpy:
21
+ return data
22
+ elif data.ndim == 0:
23
+ return data.item()
24
+ else:
25
+ return data.tolist()
26
+ elif isinstance(data, Mapping):
27
+ return {
28
+ k: data_from_storage(v, remove_numpy=remove_numpy)
29
+ for k, v in data.items()
30
+ if not k.startswith("@")
31
+ }
32
+ else:
33
+ return data
34
+
35
+
36
+ def _validate_names(names: Optional[List[str]], name_type: str) -> None:
37
+ if names is None:
38
+ return
39
+ for n in names:
40
+ if not isinstance(n, str) or not n.strip():
41
+ raise ValueError(f"Invalid {name_type} name: {n!r}")
42
+
43
+
44
+ def validate_and_init_names(
45
+ lima_names: Optional[List[str]],
46
+ counter_names: Optional[List[str]],
47
+ ) -> Tuple[List[str], List[str]]:
48
+ _validate_names(lima_names, "LIMA")
49
+ _validate_names(counter_names, "Counter")
50
+ return (
51
+ lima_names if lima_names is not None else [],
52
+ counter_names if counter_names is not None else [],
53
+ )
File without changes
@@ -0,0 +1,30 @@
1
+ import numpy
2
+ import pytest
3
+
4
+ from . import data
5
+
6
+
7
+ @pytest.fixture(scope="session")
8
+ def bliss_scan(tmpdir_factory):
9
+ tmpdir = tmpdir_factory.mktemp("sample_dataset")
10
+ npoints_per_file = 3
11
+ npoints = 31
12
+ scannr = 2
13
+ image = numpy.zeros((10, 12))
14
+ return (
15
+ str(
16
+ data.save_bliss_scan(
17
+ tmpdir,
18
+ image,
19
+ npoints_per_file,
20
+ npoints,
21
+ scannr,
22
+ subscannr=1,
23
+ lima_names=("p3", "p4"),
24
+ counter_names=("diode1", "diode2"),
25
+ sequence="add",
26
+ )
27
+ ),
28
+ npoints,
29
+ scannr,
30
+ )
@@ -0,0 +1 @@
1
+ from .bliss_scans import * # noqa F403
@@ -0,0 +1,137 @@
1
+ import os
2
+ from datetime import datetime
3
+ from pathlib import Path
4
+ from typing import Optional
5
+ from typing import Tuple
6
+
7
+ import h5py
8
+ import numpy
9
+
10
+ __all__ = ["save_bliss_scan"]
11
+
12
+
13
+ def save_bliss_scan(
14
+ tmpdir: Path,
15
+ image: numpy.ndarray,
16
+ npoints_per_file: int,
17
+ npoints: int,
18
+ scannr: int,
19
+ subscannr: int = 1,
20
+ sequence: str = "add",
21
+ lima_names: Tuple[str, ...] = ("p3",),
22
+ counter_names: Tuple[str, ...] = ("diode1", "diode2"),
23
+ positioner_names: Tuple[str, ...] = ("diffty", "diffrz"),
24
+ title: Optional[str] = None,
25
+ ) -> Path:
26
+ dataset_filename = tmpdir / "sample_dataset.h5"
27
+ lima_dirname = tmpdir / f"scan{scannr:04d}"
28
+ lima_dirname.mkdir()
29
+ with h5py.File(str(dataset_filename), mode="w") as root:
30
+ root.attrs["NX_class"] = "NXroot"
31
+ root.attrs["creator"] = "bliss"
32
+ scan = root.create_group(f"{scannr}.{subscannr}")
33
+ scan.attrs["NX_class"] = "NXentry"
34
+ if title is not None:
35
+ scan["title"] = title
36
+
37
+ instrument = scan.create_group("instrument")
38
+ instrument.attrs["NX_class"] = "NXinstrument"
39
+ measurement = scan.create_group("measurement")
40
+ measurement.attrs["NX_class"] = "NXcollection"
41
+
42
+ if positioner_names:
43
+ positioners = instrument.create_group("positioners")
44
+ positioners.attrs["NX_class"] = "NXcollection"
45
+
46
+ for name in counter_names:
47
+ _save_counter(scan, name, npoints)
48
+ for name in lima_names:
49
+ _save_lima(
50
+ scan,
51
+ name,
52
+ image,
53
+ npoints,
54
+ npoints_per_file,
55
+ lima_dirname,
56
+ scannr,
57
+ sequence,
58
+ )
59
+ for name in positioner_names:
60
+ _save_positioner(scan, name, npoints)
61
+
62
+ scan["end_time"] = datetime.now().astimezone().isoformat()
63
+ return dataset_filename
64
+
65
+
66
+ def _save_counter(scan: h5py.Group, name: str, npoints: int) -> None:
67
+ diode = _add_detector(scan, name)
68
+ diode["data"] = numpy.arange(npoints)
69
+
70
+
71
+ def _save_lima(
72
+ scan: h5py.Group,
73
+ name: str,
74
+ image: numpy.ndarray,
75
+ npoints: int,
76
+ npoints_per_file: int,
77
+ lima_dirname: Path,
78
+ scannr: int,
79
+ sequence: str,
80
+ ) -> None:
81
+ tot_shape = (npoints,) + image.shape
82
+ lima_shape = (npoints_per_file,) + image.shape
83
+ nlima = npoints // npoints_per_file + bool(npoints % npoints_per_file)
84
+ lima_dtype = float
85
+ layout = h5py.VirtualLayout(tot_shape, dtype=lima_dtype)
86
+ for i in range(nlima):
87
+ with h5py.File(str(lima_dirname / f"{name}_{i}.h5"), mode="w") as limaroot:
88
+ limaroot.attrs["NX_class"] = "NXroot"
89
+ entry = limaroot.create_group("entry_0000")
90
+ entry.attrs["NX_class"] = "NXentry"
91
+ lmeasurement = entry.create_group("measurement")
92
+ lmeasurement.attrs["NX_class"] = "NXcollection"
93
+ lmeasurement["data"] = numpy.full(lima_shape, numpy.nan)
94
+
95
+ i0 = i * npoints_per_file
96
+ i1 = min(i0 + npoints_per_file, npoints)
97
+ for itot in range(i0, i1):
98
+ if sequence == "add":
99
+ lmeasurement["data"][itot - i0, ...] = image + itot
100
+ else:
101
+ m = itot / (npoints - 1) + 1
102
+ if itot == 0:
103
+ monitor = _add_detector(scan, "monitor")
104
+ monitor["data"] = numpy.zeros(npoints)
105
+ monitor["data"][itot] = m
106
+ lmeasurement["data"][itot - i0, ...] = image * m
107
+
108
+ spath = os.path.join(f"scan{scannr:04d}", f"{name}_{i}.h5")
109
+ sdset = "/entry_0000/measurement/data"
110
+
111
+ sshape = (i1 - i0,) + lima_shape[1:]
112
+ vsource = h5py.VirtualSource(spath, sdset, shape=sshape, dtype=lima_dtype)
113
+
114
+ layout[i0:i1] = vsource[:]
115
+ detector = _add_detector(scan, name)
116
+ detector.create_virtual_dataset("data", layout, fillvalue=numpy.nan)
117
+
118
+
119
+ def _add_detector(scan: h5py.Group, name: str) -> h5py.Group:
120
+ grp = scan["instrument"].create_group(name)
121
+ grp.attrs["NX_class"] = "NXdetector"
122
+ scan["measurement"][name] = h5py.SoftLink(f"{grp.name}/data")
123
+ return grp
124
+
125
+
126
+ def _save_positioner(scan: h5py.Group, name: str, npoints: int) -> None:
127
+ positioner = _add_positioner(scan, name)
128
+ positioner["value"] = numpy.arange(npoints)
129
+
130
+
131
+ def _add_positioner(scan: h5py.Group, name: str) -> h5py.Group:
132
+ instrument = scan["instrument"]
133
+ grp = instrument.create_group(name)
134
+ grp.attrs["NX_class"] = "Nxpositioner"
135
+ scan["measurement"][name] = h5py.SoftLink(f"{grp.name}/value")
136
+ instrument["positioners"][name] = h5py.SoftLink(f"{grp.name}/value")
137
+ return grp