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.
- ewoksdata/__init__.py +0 -0
- ewoksdata/data/__init__.py +0 -0
- ewoksdata/data/bliss.py +341 -0
- ewoksdata/data/blissdata/__init__.py +31 -0
- ewoksdata/data/blissdata/blissdatav0.py +119 -0
- ewoksdata/data/blissdata/blissdatav1.py +279 -0
- ewoksdata/data/blissdata/blissdatav2.py +342 -0
- ewoksdata/data/contextiterator.py +43 -0
- ewoksdata/data/hdf5/__init__.py +23 -0
- ewoksdata/data/hdf5/config.py +186 -0
- ewoksdata/data/hdf5/dataset_writer/__init__.py +4 -0
- ewoksdata/data/hdf5/dataset_writer/_base.py +55 -0
- ewoksdata/data/hdf5/dataset_writer/_non_sequential.py +139 -0
- ewoksdata/data/hdf5/dataset_writer/_non_sequential_stack.py +158 -0
- ewoksdata/data/hdf5/dataset_writer/_sequential.py +143 -0
- ewoksdata/data/hdf5/dataset_writer/_sequential_stack.py +195 -0
- ewoksdata/data/hdf5/types.py +14 -0
- ewoksdata/data/nexus.py +187 -0
- ewoksdata/data/url.py +30 -0
- ewoksdata/data/utils.py +53 -0
- ewoksdata/tests/__init__.py +0 -0
- ewoksdata/tests/conftest.py +30 -0
- ewoksdata/tests/data/__init__.py +1 -0
- ewoksdata/tests/data/bliss_scans.py +137 -0
- ewoksdata/tests/test_contextiterator.py +61 -0
- ewoksdata/tests/test_data_bliss.py +98 -0
- ewoksdata/tests/test_data_hdf5.py +14 -0
- ewoksdata/tests/test_data_nexus.py +80 -0
- ewoksdata/tests/test_dataset_writer.py +275 -0
- ewoksdata-2.1.0.dist-info/METADATA +70 -0
- ewoksdata-2.1.0.dist-info/RECORD +34 -0
- ewoksdata-2.1.0.dist-info/WHEEL +5 -0
- ewoksdata-2.1.0.dist-info/licenses/LICENSE.md +20 -0
- ewoksdata-2.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""HDF5 configuration of optimal data storage (IO speed, compression, ...)"""
|
|
2
|
+
|
|
3
|
+
from collections.abc import Mapping
|
|
4
|
+
from numbers import Integral
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
import numpy
|
|
8
|
+
from numpy.typing import DTypeLike
|
|
9
|
+
|
|
10
|
+
try:
|
|
11
|
+
import hdf5plugin
|
|
12
|
+
except ImportError:
|
|
13
|
+
hdf5plugin = None
|
|
14
|
+
|
|
15
|
+
from .types import ShapeType
|
|
16
|
+
from .types import VarH5pyShapeType
|
|
17
|
+
from .types import VarShapeType
|
|
18
|
+
|
|
19
|
+
DEFAULT_CHUNK_NBYTES = 1 << 20
|
|
20
|
+
DEFAULT_COMPRESSION_LIMIT_NBYTES = 1 << 20
|
|
21
|
+
DEFAULT_CHUNK_SPLIT = 4
|
|
22
|
+
DEFAULT_COMPRESSION_SCHEME = "gzip-byteshuffle"
|
|
23
|
+
|
|
24
|
+
# Default data size
|
|
25
|
+
# 0D detector: 2 KB
|
|
26
|
+
# 1D detector: 2 MB
|
|
27
|
+
# 2D detector: 2 GB
|
|
28
|
+
DEFAULT_SCAN_DIM_SIZE = 512
|
|
29
|
+
DEFAULT_DETECTOR_DIM_SIZE = 1024
|
|
30
|
+
DEFAULT_DTYPE = numpy.int32
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def dtype_nbytes(dtype: DTypeLike) -> int:
|
|
34
|
+
return numpy.dtype(dtype).itemsize
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def shape_to_size(shape: ShapeType) -> int:
|
|
38
|
+
# numpy.prod gives problems on windows
|
|
39
|
+
n = 1
|
|
40
|
+
for x in shape:
|
|
41
|
+
n *= x
|
|
42
|
+
return n
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def shape_to_nbytes(shape: ShapeType, dtype: DTypeLike) -> int:
|
|
46
|
+
return shape_to_size(shape) * dtype_nbytes(dtype)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def guess_data_shape(
|
|
50
|
+
scan_shape: VarShapeType,
|
|
51
|
+
detector_shape: VarShapeType,
|
|
52
|
+
max_shape: Optional[VarH5pyShapeType],
|
|
53
|
+
) -> ShapeType:
|
|
54
|
+
scan_shape = tuple(n if n else DEFAULT_SCAN_DIM_SIZE for n in scan_shape)
|
|
55
|
+
detector_shape = tuple(n if n else DEFAULT_SCAN_DIM_SIZE for n in detector_shape)
|
|
56
|
+
data_shape = scan_shape + detector_shape
|
|
57
|
+
if max_shape:
|
|
58
|
+
assert len(max_shape) == len(
|
|
59
|
+
data_shape
|
|
60
|
+
), "HDF5 dataset shape must have the same dimensions as maxshape"
|
|
61
|
+
data_shape = tuple(
|
|
62
|
+
n1 if not n2 else max(n1, n2) for n1, n2 in zip(data_shape, max_shape)
|
|
63
|
+
)
|
|
64
|
+
return data_shape
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def guess_chunk_shape(
|
|
68
|
+
data_shape: ShapeType,
|
|
69
|
+
dtype: DTypeLike,
|
|
70
|
+
chunk_split: Optional[Integral] = None,
|
|
71
|
+
chunk_nbytes: Optional[Integral] = None,
|
|
72
|
+
) -> Optional[ShapeType]:
|
|
73
|
+
"""Try to guess the optimal chunk shape with these constraints:
|
|
74
|
+
* Split any dimension for partial access
|
|
75
|
+
* Below the maximal chunk size (1 MB by default, uncompressed)
|
|
76
|
+
|
|
77
|
+
The inner-most dimensions are split in `chunk_split` parts
|
|
78
|
+
until chunk_nbytes is reached. The chunk size in the outer
|
|
79
|
+
dimensions will be 1, unless the data size is too small.
|
|
80
|
+
"""
|
|
81
|
+
if chunk_nbytes is None:
|
|
82
|
+
chunk_nbytes = DEFAULT_CHUNK_NBYTES
|
|
83
|
+
if chunk_split is None:
|
|
84
|
+
chunk_split = DEFAULT_CHUNK_SPLIT
|
|
85
|
+
itemsize = dtype_nbytes(dtype)
|
|
86
|
+
size = shape_to_size(data_shape)
|
|
87
|
+
nbytes = size * itemsize
|
|
88
|
+
if nbytes <= chunk_nbytes:
|
|
89
|
+
return None
|
|
90
|
+
|
|
91
|
+
max_size = chunk_nbytes // itemsize
|
|
92
|
+
current_size = 1
|
|
93
|
+
chunk_shape = []
|
|
94
|
+
for n_i in data_shape[-1::-1]:
|
|
95
|
+
if current_size >= max_size:
|
|
96
|
+
c_i = 1
|
|
97
|
+
else:
|
|
98
|
+
a = int(numpy.ceil(n_i / chunk_split))
|
|
99
|
+
b = int(numpy.ceil(max_size / current_size))
|
|
100
|
+
c_i = min(a, b)
|
|
101
|
+
chunk_shape.append(c_i)
|
|
102
|
+
current_size *= c_i
|
|
103
|
+
chunk_shape = tuple(chunk_shape[::-1])
|
|
104
|
+
if chunk_shape == data_shape:
|
|
105
|
+
return None
|
|
106
|
+
return chunk_shape
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def guess_compression(
|
|
110
|
+
data_shape: ShapeType,
|
|
111
|
+
dtype: DTypeLike,
|
|
112
|
+
compression_limit_nbytes: Optional[Integral] = None,
|
|
113
|
+
) -> bool:
|
|
114
|
+
"""Compression is needed when the total data size exceeds the limits (1 MB by default)."""
|
|
115
|
+
if compression_limit_nbytes is None:
|
|
116
|
+
compression_limit_nbytes = DEFAULT_COMPRESSION_LIMIT_NBYTES
|
|
117
|
+
nbytes = shape_to_nbytes(data_shape, dtype)
|
|
118
|
+
return nbytes > compression_limit_nbytes
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def get_compression_arguments(compression_scheme: Optional[str] = None) -> Mapping:
|
|
122
|
+
if compression_scheme:
|
|
123
|
+
compression_scheme = compression_scheme.lower()
|
|
124
|
+
if compression_scheme is None:
|
|
125
|
+
compression_scheme = DEFAULT_COMPRESSION_SCHEME
|
|
126
|
+
if compression_scheme == "none":
|
|
127
|
+
return dict()
|
|
128
|
+
elif compression_scheme == "gzip":
|
|
129
|
+
return {"compression": "gzip"}
|
|
130
|
+
elif compression_scheme == "byteshuffle":
|
|
131
|
+
return {"shuffle": True}
|
|
132
|
+
elif compression_scheme == "gzip-byteshuffle":
|
|
133
|
+
return {"compression": "gzip", "shuffle": True}
|
|
134
|
+
elif compression_scheme == "bitshuffle":
|
|
135
|
+
if hdf5plugin is None:
|
|
136
|
+
raise RuntimeError(
|
|
137
|
+
"Writer does not support HDF5 'bitshuffle' compression. Install the hdf5plugin library"
|
|
138
|
+
)
|
|
139
|
+
return hdf5plugin.Bitshuffle(nelems=0, lz4=False)
|
|
140
|
+
elif compression_scheme == "lz4-bitshuffle":
|
|
141
|
+
if hdf5plugin is None:
|
|
142
|
+
raise RuntimeError(
|
|
143
|
+
"Writer does not support HDF5 'bitshuffle' compression. Install the hdf5plugin library"
|
|
144
|
+
)
|
|
145
|
+
return hdf5plugin.Bitshuffle(nelems=0, lz4=True)
|
|
146
|
+
else:
|
|
147
|
+
raise ValueError(f"Unknown HDF5 compression '{compression_scheme}'")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def guess_dataset_config(
|
|
151
|
+
scan_shape: VarShapeType,
|
|
152
|
+
detector_shape: VarShapeType,
|
|
153
|
+
dtype: Optional[DTypeLike] = None,
|
|
154
|
+
chunk_split: Optional[Integral] = None,
|
|
155
|
+
chunk_nbytes: Optional[Integral] = None,
|
|
156
|
+
compression_limit_nbytes: Optional[Integral] = None,
|
|
157
|
+
compression_scheme: Optional[str] = None,
|
|
158
|
+
max_shape: Optional[VarH5pyShapeType] = None,
|
|
159
|
+
) -> dict:
|
|
160
|
+
"""Dataset configuration passed the `h5py.Group.create_dataset` for optimal
|
|
161
|
+
storage (IO speed, compression, ...)
|
|
162
|
+
"""
|
|
163
|
+
data_shape = guess_data_shape(
|
|
164
|
+
scan_shape=scan_shape, detector_shape=detector_shape, max_shape=max_shape
|
|
165
|
+
)
|
|
166
|
+
if dtype is None:
|
|
167
|
+
dtype = DEFAULT_DTYPE
|
|
168
|
+
chunk_shape = guess_chunk_shape(
|
|
169
|
+
data_shape=data_shape,
|
|
170
|
+
dtype=dtype,
|
|
171
|
+
chunk_split=chunk_split,
|
|
172
|
+
chunk_nbytes=chunk_nbytes,
|
|
173
|
+
)
|
|
174
|
+
config = {"chunks": chunk_shape}
|
|
175
|
+
compression = guess_compression(
|
|
176
|
+
data_shape=data_shape,
|
|
177
|
+
dtype=dtype,
|
|
178
|
+
compression_limit_nbytes=compression_limit_nbytes,
|
|
179
|
+
)
|
|
180
|
+
if compression:
|
|
181
|
+
config.update(get_compression_arguments(compression_scheme=compression_scheme))
|
|
182
|
+
chunking_required = compression or max_shape is not None
|
|
183
|
+
if chunking_required and chunk_shape is None:
|
|
184
|
+
# Do not let h5py guess the chunk size
|
|
185
|
+
config["chunks"] = data_shape
|
|
186
|
+
return config
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import time
|
|
2
|
+
from typing import Optional
|
|
3
|
+
|
|
4
|
+
import h5py
|
|
5
|
+
import numpy
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class DatasetWriterBase:
|
|
9
|
+
def __init__(
|
|
10
|
+
self,
|
|
11
|
+
parent: h5py.Group,
|
|
12
|
+
name: str,
|
|
13
|
+
attrs: Optional[dict] = None,
|
|
14
|
+
flush_period: Optional[float] = None,
|
|
15
|
+
) -> None:
|
|
16
|
+
self._file = parent.file
|
|
17
|
+
self._parent = parent
|
|
18
|
+
self._name = name
|
|
19
|
+
self._attrs = attrs
|
|
20
|
+
self._dataset_name = f"{parent.name}/{name}"
|
|
21
|
+
self._dataset = None
|
|
22
|
+
self._flush_period = flush_period
|
|
23
|
+
self._last_flush = None
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def dataset_name(self) -> str:
|
|
27
|
+
return self._dataset_name
|
|
28
|
+
|
|
29
|
+
def __enter__(self) -> "DatasetWriterBase":
|
|
30
|
+
return self
|
|
31
|
+
|
|
32
|
+
def __exit__(self, *args) -> None:
|
|
33
|
+
self.flush_buffer()
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def dataset(self) -> Optional[h5py.Dataset]:
|
|
37
|
+
return self._dataset
|
|
38
|
+
|
|
39
|
+
def _create_dataset(self, first_data_point: numpy.ndarray) -> h5py.Dataset:
|
|
40
|
+
raise NotImplementedError
|
|
41
|
+
|
|
42
|
+
def flush_buffer(self, align: bool = False) -> bool:
|
|
43
|
+
raise NotImplementedError
|
|
44
|
+
|
|
45
|
+
def _flush_time_expired(self) -> bool:
|
|
46
|
+
if self._flush_period is None:
|
|
47
|
+
return False
|
|
48
|
+
if self._last_flush is None:
|
|
49
|
+
self._last_flush = time.time()
|
|
50
|
+
return False
|
|
51
|
+
return (time.time() - self._last_flush) >= self._flush_period
|
|
52
|
+
|
|
53
|
+
def _flush_hdf5(self) -> None:
|
|
54
|
+
"""Explicit HDF5 flush for non-locking readers."""
|
|
55
|
+
self._file.flush()
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import List
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import h5py
|
|
7
|
+
import numpy
|
|
8
|
+
from numpy.typing import ArrayLike
|
|
9
|
+
|
|
10
|
+
from ..config import guess_dataset_config
|
|
11
|
+
from ..types import StrictPositiveIntegral
|
|
12
|
+
from ._base import DatasetWriterBase
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IndexedDatasetWriter(DatasetWriterBase):
|
|
18
|
+
"""Append arrays of the same shape to a new HDF5 dataset in a potentially non-sequential manner.
|
|
19
|
+
|
|
20
|
+
Instead of creating a dataset with the :code:`h5py` API
|
|
21
|
+
|
|
22
|
+
.. code-block::python
|
|
23
|
+
|
|
24
|
+
h5group["mydataset"] = [[1,2,3], [4,5,6], [7,8,9]]
|
|
25
|
+
|
|
26
|
+
it can be done like this
|
|
27
|
+
|
|
28
|
+
.. code-block::python
|
|
29
|
+
|
|
30
|
+
with IndexedDatasetWriter(h5group, "mydataset") as writer:
|
|
31
|
+
writer.add_point(1, [4,5,6])
|
|
32
|
+
writer.add_points([0, 2], [[1,2,3], [7,8,9]])
|
|
33
|
+
|
|
34
|
+
Chunk size determination, compression and flushing is handled.
|
|
35
|
+
"""
|
|
36
|
+
|
|
37
|
+
def __init__(
|
|
38
|
+
self,
|
|
39
|
+
parent: h5py.Group,
|
|
40
|
+
name: str,
|
|
41
|
+
npoints: Optional[StrictPositiveIntegral] = None,
|
|
42
|
+
attrs: Optional[dict] = None,
|
|
43
|
+
flush_period: Optional[float] = None,
|
|
44
|
+
overwrite: bool = False,
|
|
45
|
+
) -> None:
|
|
46
|
+
super().__init__(parent, name, attrs=attrs, flush_period=flush_period)
|
|
47
|
+
self._npoints = npoints
|
|
48
|
+
self._overwrite = overwrite
|
|
49
|
+
self._npoints_added: int = 0
|
|
50
|
+
|
|
51
|
+
self._buffer: List[tuple[int, ArrayLike]] = []
|
|
52
|
+
|
|
53
|
+
def _create_dataset(self, first_data_point: numpy.ndarray) -> h5py.Dataset:
|
|
54
|
+
scan_shape = (self._npoints,)
|
|
55
|
+
detector_shape = first_data_point.shape
|
|
56
|
+
dtype = first_data_point.dtype
|
|
57
|
+
|
|
58
|
+
if self._npoints is None:
|
|
59
|
+
shape = (1,) + detector_shape
|
|
60
|
+
max_shape = scan_shape + detector_shape
|
|
61
|
+
else:
|
|
62
|
+
shape = scan_shape + detector_shape
|
|
63
|
+
max_shape = None
|
|
64
|
+
|
|
65
|
+
options = guess_dataset_config(
|
|
66
|
+
scan_shape, detector_shape, dtype=dtype, max_shape=max_shape
|
|
67
|
+
)
|
|
68
|
+
options.update(
|
|
69
|
+
shape=shape,
|
|
70
|
+
dtype=dtype,
|
|
71
|
+
fillvalue=numpy.nan, # converts to 0 for integers
|
|
72
|
+
)
|
|
73
|
+
if max_shape:
|
|
74
|
+
options["maxshape"] = max_shape
|
|
75
|
+
|
|
76
|
+
if self._overwrite and self._name in self._parent:
|
|
77
|
+
del self._parent[self._name]
|
|
78
|
+
|
|
79
|
+
dset = self._parent.create_dataset(self._name, **options)
|
|
80
|
+
if self._attrs:
|
|
81
|
+
dset.attrs.update(self._attrs)
|
|
82
|
+
return dset
|
|
83
|
+
|
|
84
|
+
@property
|
|
85
|
+
def npoints_added(self) -> int:
|
|
86
|
+
return self._npoints_added
|
|
87
|
+
|
|
88
|
+
def add_point(
|
|
89
|
+
self,
|
|
90
|
+
data: ArrayLike,
|
|
91
|
+
index: int,
|
|
92
|
+
) -> bool:
|
|
93
|
+
"""Append one array to the dataset."""
|
|
94
|
+
if self._dataset is None:
|
|
95
|
+
self._dataset = self._create_dataset(numpy.asarray(data))
|
|
96
|
+
|
|
97
|
+
self._buffer.append((index, data))
|
|
98
|
+
self._npoints_added += 1
|
|
99
|
+
return self.flush_buffer()
|
|
100
|
+
|
|
101
|
+
def add_points(self, data: ArrayLike, indices: ArrayLike) -> bool:
|
|
102
|
+
"""Append several arrays at once to the dataset."""
|
|
103
|
+
if self._dataset is None:
|
|
104
|
+
self._dataset = self._create_dataset(numpy.asarray(data[0]))
|
|
105
|
+
|
|
106
|
+
self._buffer.extend(zip(indices, data))
|
|
107
|
+
self._npoints_added += len(data)
|
|
108
|
+
return self.flush_buffer()
|
|
109
|
+
|
|
110
|
+
def flush_buffer(self, align: bool = False) -> bool:
|
|
111
|
+
if not self._buffer:
|
|
112
|
+
return False
|
|
113
|
+
|
|
114
|
+
if align:
|
|
115
|
+
logger.debug(
|
|
116
|
+
"Chunk-aligned flushing is only supported by DatasetWriter. "
|
|
117
|
+
"Only flush when `flush_period` has passed since the last flush."
|
|
118
|
+
)
|
|
119
|
+
if self._flush_time_expired():
|
|
120
|
+
return False
|
|
121
|
+
|
|
122
|
+
indices, values = zip(*self._buffer)
|
|
123
|
+
indices = numpy.asarray(indices)
|
|
124
|
+
values = numpy.asarray(values)
|
|
125
|
+
|
|
126
|
+
# Enlarge the dataset when needed
|
|
127
|
+
max_index = int(indices.max())
|
|
128
|
+
if max_index >= self._dataset.shape[0]:
|
|
129
|
+
self._dataset.resize(max_index + 1, axis=0)
|
|
130
|
+
|
|
131
|
+
# Copy data from buffer to HDF5
|
|
132
|
+
self._dataset[indices] = values
|
|
133
|
+
|
|
134
|
+
# Remove copied data from buffer
|
|
135
|
+
self._buffer.clear()
|
|
136
|
+
|
|
137
|
+
self._flush_hdf5()
|
|
138
|
+
self._last_flush = time.time()
|
|
139
|
+
return True
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
import time
|
|
3
|
+
from typing import List
|
|
4
|
+
from typing import Optional
|
|
5
|
+
|
|
6
|
+
import h5py
|
|
7
|
+
import numpy
|
|
8
|
+
from numpy.typing import ArrayLike
|
|
9
|
+
|
|
10
|
+
from ..config import guess_dataset_config
|
|
11
|
+
from ..types import StrictPositiveIntegral
|
|
12
|
+
from ._base import DatasetWriterBase
|
|
13
|
+
|
|
14
|
+
logger = logging.getLogger(__name__)
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class IndexedStackDatasetWriter(DatasetWriterBase):
|
|
18
|
+
"""Append arrays of the same shape to each item of a new HDF5 dataset
|
|
19
|
+
in a potentially non-sequential manner per item. So each item of the HDF5 dataset is a
|
|
20
|
+
stack to which we can append data in a sequential manner.
|
|
21
|
+
|
|
22
|
+
Instead of creating a dataset with the :code:`h5py` API
|
|
23
|
+
|
|
24
|
+
.. code-block::python
|
|
25
|
+
|
|
26
|
+
stack0 = [[1,2,3], [4,5,6], [7,8,9]]
|
|
27
|
+
stack1 = [[10,11,12], [13,14,15], [16,17,18]]
|
|
28
|
+
h5group["mydataset"] = [stack0, stack1]
|
|
29
|
+
|
|
30
|
+
it can be done like this
|
|
31
|
+
|
|
32
|
+
.. code-block::python
|
|
33
|
+
|
|
34
|
+
with IndexedStackDatasetWriter(h5group, "mydataset") as writer:
|
|
35
|
+
writer.add_point([4,5,6], 0, 1)
|
|
36
|
+
writer.add_point([13,14,15], 1, 1)
|
|
37
|
+
writer.add_points([[10,11,12], [16,17,18]], [1, 1], [0, 2])
|
|
38
|
+
writer.add_points([[1,2,3], [7,8,9]], [0, 0], [0, 2])
|
|
39
|
+
|
|
40
|
+
Chunk size determination, compression and flushing is handled.
|
|
41
|
+
"""
|
|
42
|
+
|
|
43
|
+
def __init__(
|
|
44
|
+
self,
|
|
45
|
+
parent: h5py.Group,
|
|
46
|
+
name: str,
|
|
47
|
+
npoints: Optional[StrictPositiveIntegral] = None,
|
|
48
|
+
nstack: Optional[StrictPositiveIntegral] = None,
|
|
49
|
+
attrs: Optional[dict] = None,
|
|
50
|
+
flush_period: Optional[float] = None,
|
|
51
|
+
) -> None:
|
|
52
|
+
super().__init__(parent, name, attrs=attrs, flush_period=flush_period)
|
|
53
|
+
self._npoints = npoints
|
|
54
|
+
self._nstack = nstack
|
|
55
|
+
self._buffer: List[tuple[int, int, ArrayLike]] = []
|
|
56
|
+
|
|
57
|
+
def _create_dataset(
|
|
58
|
+
self, first_data_point: ArrayLike, stack_index: int
|
|
59
|
+
) -> h5py.Dataset:
|
|
60
|
+
scan_shape = (self._nstack, self._npoints)
|
|
61
|
+
first_data_point = numpy.asarray(first_data_point)
|
|
62
|
+
detector_shape = first_data_point.shape
|
|
63
|
+
dtype = first_data_point.dtype
|
|
64
|
+
|
|
65
|
+
if self._nstack is None or self._npoints is None:
|
|
66
|
+
shape = (1, 1) + detector_shape
|
|
67
|
+
max_shape = scan_shape + detector_shape
|
|
68
|
+
else:
|
|
69
|
+
shape = scan_shape + detector_shape
|
|
70
|
+
max_shape = None
|
|
71
|
+
|
|
72
|
+
options = guess_dataset_config(
|
|
73
|
+
scan_shape, detector_shape, dtype=dtype, max_shape=max_shape
|
|
74
|
+
)
|
|
75
|
+
options.update(
|
|
76
|
+
shape=shape,
|
|
77
|
+
dtype=dtype,
|
|
78
|
+
fillvalue=numpy.nan, # converts to 0 for integers
|
|
79
|
+
)
|
|
80
|
+
if max_shape:
|
|
81
|
+
options["maxshape"] = max_shape
|
|
82
|
+
|
|
83
|
+
dset = self._parent.create_dataset(self._name, **options)
|
|
84
|
+
if self._attrs:
|
|
85
|
+
dset.attrs.update(self._attrs)
|
|
86
|
+
return dset
|
|
87
|
+
|
|
88
|
+
def add_point(self, data: ArrayLike, stack_index: int, point_index: int) -> bool:
|
|
89
|
+
"""Append one array to one stack of the dataset."""
|
|
90
|
+
if self._dataset is None:
|
|
91
|
+
self._dataset = self._create_dataset(data, stack_index)
|
|
92
|
+
|
|
93
|
+
self._buffer.append((stack_index, point_index, data))
|
|
94
|
+
return self.flush_buffer()
|
|
95
|
+
|
|
96
|
+
def add_points(
|
|
97
|
+
self,
|
|
98
|
+
data: ArrayLike,
|
|
99
|
+
stack_indices: ArrayLike,
|
|
100
|
+
point_indices: ArrayLike,
|
|
101
|
+
) -> bool:
|
|
102
|
+
"""Append several arrays at once to one stack of the dataset."""
|
|
103
|
+
if not (len(stack_indices) == len(point_indices) == len(data)):
|
|
104
|
+
raise ValueError(
|
|
105
|
+
"stack_indices, point_indices, and data must have same length"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
if self._dataset is None:
|
|
109
|
+
self._dataset = self._create_dataset(data[0], stack_indices[0])
|
|
110
|
+
|
|
111
|
+
self._buffer.extend(
|
|
112
|
+
zip(
|
|
113
|
+
map(int, stack_indices),
|
|
114
|
+
map(int, point_indices),
|
|
115
|
+
data,
|
|
116
|
+
)
|
|
117
|
+
)
|
|
118
|
+
return self.flush_buffer()
|
|
119
|
+
|
|
120
|
+
def flush_buffer(self, align: bool = False) -> bool:
|
|
121
|
+
if not self._buffer:
|
|
122
|
+
return False
|
|
123
|
+
|
|
124
|
+
if align:
|
|
125
|
+
logger.debug(
|
|
126
|
+
"Chunk-aligned flushing is only supported by StackDatasetWriter. "
|
|
127
|
+
"Only flush when `flush_period` has passed since the last flush."
|
|
128
|
+
)
|
|
129
|
+
if self._flush_time_expired():
|
|
130
|
+
return False
|
|
131
|
+
|
|
132
|
+
stack_indices, point_indices, values = zip(*self._buffer)
|
|
133
|
+
stack_indices = numpy.asarray(stack_indices)
|
|
134
|
+
point_indices = numpy.asarray(point_indices)
|
|
135
|
+
values = numpy.asarray(values)
|
|
136
|
+
|
|
137
|
+
# Enlarge the dataset when needed
|
|
138
|
+
max_stack = int(stack_indices.max())
|
|
139
|
+
max_point = int(point_indices.max())
|
|
140
|
+
|
|
141
|
+
shape0, shape1 = self._dataset.shape[:2]
|
|
142
|
+
|
|
143
|
+
if max_stack >= shape0:
|
|
144
|
+
self._dataset.resize(max_stack + 1, axis=0)
|
|
145
|
+
|
|
146
|
+
if max_point >= shape1:
|
|
147
|
+
self._dataset.resize(max_point + 1, axis=1)
|
|
148
|
+
|
|
149
|
+
# Copy data from buffer to HDF5
|
|
150
|
+
for i0, i1, value in zip(stack_indices, point_indices, values):
|
|
151
|
+
self._dataset[i0, i1] = value
|
|
152
|
+
|
|
153
|
+
# Remove copied data from buffer
|
|
154
|
+
self._buffer.clear()
|
|
155
|
+
|
|
156
|
+
self._flush_hdf5()
|
|
157
|
+
self._last_flush = time.time()
|
|
158
|
+
return True
|
|
@@ -0,0 +1,143 @@
|
|
|
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 DatasetWriter(DatasetWriterBase):
|
|
15
|
+
"""Append arrays of the same shape to a new HDF5 dataset in a sequential manner.
|
|
16
|
+
|
|
17
|
+
Instead of creating a dataset with the :code:`h5py` API
|
|
18
|
+
|
|
19
|
+
.. code-block::python
|
|
20
|
+
|
|
21
|
+
h5group["mydataset"] = [[1,2,3], [4,5,6], [7,8,9]]
|
|
22
|
+
|
|
23
|
+
it can be done like this
|
|
24
|
+
|
|
25
|
+
.. code-block::python
|
|
26
|
+
|
|
27
|
+
with DatasetWriter(h5group, "mydataset") as writer:
|
|
28
|
+
writer.add_point([1,2,3])
|
|
29
|
+
writer.add_points([[4,5,6], [7,8,9]])
|
|
30
|
+
|
|
31
|
+
Chunk size determination, chunk-aligned writing, compression and flushing is handled.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
def __init__(
|
|
35
|
+
self,
|
|
36
|
+
parent: h5py.Group,
|
|
37
|
+
name: str,
|
|
38
|
+
npoints: Optional[StrictPositiveIntegral] = None,
|
|
39
|
+
attrs: Optional[dict] = None,
|
|
40
|
+
flush_period: Optional[float] = None,
|
|
41
|
+
overwrite: bool = False,
|
|
42
|
+
) -> None:
|
|
43
|
+
super().__init__(parent, name, attrs=attrs, flush_period=flush_period)
|
|
44
|
+
self._npoints = npoints
|
|
45
|
+
self._overwrite = overwrite
|
|
46
|
+
self._chunked: bool = False
|
|
47
|
+
self._npoints_added: int = 0
|
|
48
|
+
|
|
49
|
+
self._buffer: List[ArrayLike] = list()
|
|
50
|
+
self._chunk_size: int = 0
|
|
51
|
+
self._flushed_size: int = 0
|
|
52
|
+
|
|
53
|
+
def _create_dataset(self, first_data_point: numpy.ndarray) -> h5py.Dataset:
|
|
54
|
+
scan_shape = (self._npoints,)
|
|
55
|
+
detector_shape = first_data_point.shape
|
|
56
|
+
dtype = first_data_point.dtype
|
|
57
|
+
|
|
58
|
+
if self._npoints is None:
|
|
59
|
+
shape = (1,) + detector_shape
|
|
60
|
+
max_shape = scan_shape + detector_shape
|
|
61
|
+
else:
|
|
62
|
+
shape = scan_shape + detector_shape
|
|
63
|
+
max_shape = None
|
|
64
|
+
|
|
65
|
+
options = guess_dataset_config(
|
|
66
|
+
scan_shape, detector_shape, dtype=dtype, max_shape=max_shape
|
|
67
|
+
)
|
|
68
|
+
options.update(
|
|
69
|
+
shape=shape,
|
|
70
|
+
dtype=dtype,
|
|
71
|
+
fillvalue=numpy.nan, # converts to 0 for integers
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
if max_shape:
|
|
75
|
+
options["maxshape"] = max_shape
|
|
76
|
+
|
|
77
|
+
if options["chunks"]:
|
|
78
|
+
self._chunked = True
|
|
79
|
+
self._chunk_size = options["chunks"][0]
|
|
80
|
+
|
|
81
|
+
if self._overwrite and self._name in self._parent:
|
|
82
|
+
del self._parent[self._name]
|
|
83
|
+
|
|
84
|
+
dset = self._parent.create_dataset(self._name, **options)
|
|
85
|
+
if self._attrs:
|
|
86
|
+
dset.attrs.update(self._attrs)
|
|
87
|
+
return dset
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def npoints_added(self) -> int:
|
|
91
|
+
return self._npoints_added
|
|
92
|
+
|
|
93
|
+
def add_point(self, data: ArrayLike) -> bool:
|
|
94
|
+
"""Append one array to the dataset."""
|
|
95
|
+
if self._dataset is None:
|
|
96
|
+
self._dataset = self._create_dataset(data)
|
|
97
|
+
|
|
98
|
+
self._buffer.append(data)
|
|
99
|
+
self._npoints_added += 1
|
|
100
|
+
return self.flush_buffer(align=True)
|
|
101
|
+
|
|
102
|
+
def add_points(self, data: ArrayLike) -> bool:
|
|
103
|
+
"""Append several arrays at once to the dataset."""
|
|
104
|
+
if self._dataset is None:
|
|
105
|
+
self._dataset = self._create_dataset(data[0])
|
|
106
|
+
|
|
107
|
+
self._buffer.extend(data)
|
|
108
|
+
self._npoints_added += len(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
|
|
113
|
+
chunk_size = len(self._buffer)
|
|
114
|
+
|
|
115
|
+
if self._flush_time_expired():
|
|
116
|
+
flush_size = chunk_size
|
|
117
|
+
elif align and self._chunked:
|
|
118
|
+
n = chunk_size + (self._flushed_size % self._chunk_size)
|
|
119
|
+
flush_size = n // self._chunk_size * self._chunk_size
|
|
120
|
+
flush_size = min(flush_size, chunk_size)
|
|
121
|
+
else:
|
|
122
|
+
flush_size = chunk_size
|
|
123
|
+
|
|
124
|
+
if flush_size == 0:
|
|
125
|
+
return False
|
|
126
|
+
|
|
127
|
+
# Enlarge the dataset when needed
|
|
128
|
+
nalloc = self._dataset.shape[0]
|
|
129
|
+
istart = self._flushed_size
|
|
130
|
+
flushed_size = istart + flush_size
|
|
131
|
+
if self._chunked and flushed_size > nalloc:
|
|
132
|
+
self._dataset.resize(flushed_size, axis=0)
|
|
133
|
+
|
|
134
|
+
# Copy data from buffer to HDF5
|
|
135
|
+
self._dataset[istart : istart + flush_size] = self._buffer[:flush_size]
|
|
136
|
+
|
|
137
|
+
# Remove copied data from buffer
|
|
138
|
+
self._buffer = self._buffer[flush_size:]
|
|
139
|
+
self._flushed_size = flushed_size
|
|
140
|
+
|
|
141
|
+
self._flush_hdf5()
|
|
142
|
+
self._last_flush = time.time()
|
|
143
|
+
return True
|