ComDAS 0.1.0__tar.gz → 0.2.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 (43) hide show
  1. {comdas-0.1.0 → comdas-0.2.0}/.github/workflows/publish.yml +1 -0
  2. {comdas-0.1.0 → comdas-0.2.0}/.github/workflows/testing.yml +1 -0
  3. {comdas-0.1.0 → comdas-0.2.0}/ComDAS.egg-info/PKG-INFO +8 -8
  4. {comdas-0.1.0 → comdas-0.2.0}/PKG-INFO +8 -8
  5. {comdas-0.1.0 → comdas-0.2.0}/README.md +7 -7
  6. {comdas-0.1.0 → comdas-0.2.0}/comdas/arrays/duck_array.py +157 -76
  7. {comdas-0.1.0 → comdas-0.2.0}/comdas/codecs/base.py +44 -3
  8. {comdas-0.1.0 → comdas-0.2.0}/comdas/codecs/svd.py +19 -10
  9. {comdas-0.1.0 → comdas-0.2.0}/comdas/codecs/wavelet.py +23 -5
  10. {comdas-0.1.0 → comdas-0.2.0}/comdas/io/h5_container.py +61 -13
  11. {comdas-0.1.0 → comdas-0.2.0}/pyproject.toml +1 -1
  12. {comdas-0.1.0 → comdas-0.2.0}/tests/test_codecs.py +92 -0
  13. {comdas-0.1.0 → comdas-0.2.0}/tests/test_duck_array.py +148 -0
  14. comdas-0.2.0/tests/test_h5_container.py +241 -0
  15. comdas-0.1.0/tests/test_h5_container.py +0 -129
  16. {comdas-0.1.0 → comdas-0.2.0}/.github/workflows/docs.yml +0 -0
  17. {comdas-0.1.0 → comdas-0.2.0}/.gitignore +0 -0
  18. {comdas-0.1.0 → comdas-0.2.0}/ComDAS.egg-info/SOURCES.txt +0 -0
  19. {comdas-0.1.0 → comdas-0.2.0}/ComDAS.egg-info/dependency_links.txt +0 -0
  20. {comdas-0.1.0 → comdas-0.2.0}/ComDAS.egg-info/entry_points.txt +0 -0
  21. {comdas-0.1.0 → comdas-0.2.0}/ComDAS.egg-info/requires.txt +0 -0
  22. {comdas-0.1.0 → comdas-0.2.0}/ComDAS.egg-info/top_level.txt +0 -0
  23. {comdas-0.1.0 → comdas-0.2.0}/LICENSE +0 -0
  24. {comdas-0.1.0 → comdas-0.2.0}/comdas/__init__.py +0 -0
  25. {comdas-0.1.0 → comdas-0.2.0}/comdas/arrays/__init__.py +0 -0
  26. {comdas-0.1.0 → comdas-0.2.0}/comdas/codecs/__init__.py +0 -0
  27. {comdas-0.1.0 → comdas-0.2.0}/comdas/io/__init__.py +0 -0
  28. {comdas-0.1.0 → comdas-0.2.0}/comdas/utils.py +0 -0
  29. {comdas-0.1.0 → comdas-0.2.0}/docs/Makefile +0 -0
  30. {comdas-0.1.0 → comdas-0.2.0}/docs/api/index.rst +0 -0
  31. {comdas-0.1.0 → comdas-0.2.0}/docs/codecs/add_a_codec.rst +0 -0
  32. {comdas-0.1.0 → comdas-0.2.0}/docs/codecs/svd.rst +0 -0
  33. {comdas-0.1.0 → comdas-0.2.0}/docs/codecs/wavelet.rst +0 -0
  34. {comdas-0.1.0 → comdas-0.2.0}/docs/conf.py +0 -0
  35. {comdas-0.1.0 → comdas-0.2.0}/docs/examples/compression_comparison.ipynb +0 -0
  36. {comdas-0.1.0 → comdas-0.2.0}/docs/index.rst +0 -0
  37. {comdas-0.1.0 → comdas-0.2.0}/docs/installation.rst +0 -0
  38. {comdas-0.1.0 → comdas-0.2.0}/docs/make.bat +0 -0
  39. {comdas-0.1.0 → comdas-0.2.0}/docs/quickstart.rst +0 -0
  40. {comdas-0.1.0 → comdas-0.2.0}/setup.cfg +0 -0
  41. {comdas-0.1.0 → comdas-0.2.0}/tests/__init__.py +0 -0
  42. {comdas-0.1.0 → comdas-0.2.0}/tests/_dummy_codec.py +0 -0
  43. {comdas-0.1.0 → comdas-0.2.0}/tests/test_codec_base.py +0 -0
@@ -3,6 +3,7 @@ name: Upload Python Package
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
+ workflow_dispatch:
6
7
 
7
8
  permissions:
8
9
  contents: read
@@ -9,6 +9,7 @@ on:
9
9
  - ".github/workflows/testing.yml"
10
10
  pull_request:
11
11
  branches: [main]
12
+ workflow_dispatch:
12
13
 
13
14
  permissions:
14
15
  contents: read
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ComDAS
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: DASCORE companion package that adds support for compressed DAS data
5
5
  Author-email: Byron-Selvage <bselvage@mines.edu>
6
6
  License: LGPL-3.0-or-later
@@ -44,8 +44,8 @@ With ComDAS you can:
44
44
 
45
45
  | Codec | Main setting | Reference |
46
46
  | --- | --- | --- |
47
- | [SVD](https://byron-selvage.github.io/ComDAS/codecs/svd.html) | `rank` or retained `energy` | [NumPy SVD](https://numpy.org/doc/1.22/reference/generated/numpy.linalg.svd.html) |
48
- | [Wavelet](https://byron-selvage.github.io/ComDAS/codecs/wavelet.html) | `keep_fraction` or `threshold` | [PyWavelets](https://doi.org/10.21105/joss.01237); [DeVore et al. (1992)](https://doi.org/10.1109/18.119733) |
47
+ | [SVD](https://dasdae.github.io/ComDAS/codecs/svd.html) | `rank` or retained `energy` | [NumPy SVD](https://numpy.org/doc/1.22/reference/generated/numpy.linalg.svd.html) |
48
+ | [Wavelet](https://dasdae.github.io/ComDAS/codecs/wavelet.html) | `keep_fraction` or `threshold` | [PyWavelets](https://doi.org/10.21105/joss.01237); [DeVore et al. (1992)](https://doi.org/10.1109/18.119733) |
49
49
 
50
50
  ## Installation
51
51
 
@@ -57,12 +57,12 @@ pip install comdas
57
57
 
58
58
  ## Documentation
59
59
 
60
- Full documentation is available at [byron-selvage.github.io/ComDAS](https://byron-selvage.github.io/ComDAS/), including:
60
+ Full documentation is available at [dasdae.github.io/ComDAS](https://dasdae.github.io/ComDAS/), including:
61
61
 
62
- - [Quick start](https://byron-selvage.github.io/ComDAS/quickstart.html): compress a patch, save a compressed file, and read it back
63
- - [Codecs](https://byron-selvage.github.io/ComDAS/codecs/svd.html): configuration options and examples for each codec
64
- - [Examples](https://byron-selvage.github.io/ComDAS/examples/compression_comparison.html)
65
- - [API reference](https://byron-selvage.github.io/ComDAS/api/index.html)
62
+ - [Quick start](https://dasdae.github.io/ComDAS/quickstart.html): compress a patch, save a compressed file, and read it back
63
+ - [Codecs](https://dasdae.github.io/ComDAS/codecs/svd.html): configuration options and examples for each codec
64
+ - [Examples](https://dasdae.github.io/ComDAS/examples/compression_comparison.html)
65
+ - [API reference](https://dasdae.github.io/ComDAS/api/index.html)
66
66
 
67
67
  ## License
68
68
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ComDAS
3
- Version: 0.1.0
3
+ Version: 0.2.0
4
4
  Summary: DASCORE companion package that adds support for compressed DAS data
5
5
  Author-email: Byron-Selvage <bselvage@mines.edu>
6
6
  License: LGPL-3.0-or-later
@@ -44,8 +44,8 @@ With ComDAS you can:
44
44
 
45
45
  | Codec | Main setting | Reference |
46
46
  | --- | --- | --- |
47
- | [SVD](https://byron-selvage.github.io/ComDAS/codecs/svd.html) | `rank` or retained `energy` | [NumPy SVD](https://numpy.org/doc/1.22/reference/generated/numpy.linalg.svd.html) |
48
- | [Wavelet](https://byron-selvage.github.io/ComDAS/codecs/wavelet.html) | `keep_fraction` or `threshold` | [PyWavelets](https://doi.org/10.21105/joss.01237); [DeVore et al. (1992)](https://doi.org/10.1109/18.119733) |
47
+ | [SVD](https://dasdae.github.io/ComDAS/codecs/svd.html) | `rank` or retained `energy` | [NumPy SVD](https://numpy.org/doc/1.22/reference/generated/numpy.linalg.svd.html) |
48
+ | [Wavelet](https://dasdae.github.io/ComDAS/codecs/wavelet.html) | `keep_fraction` or `threshold` | [PyWavelets](https://doi.org/10.21105/joss.01237); [DeVore et al. (1992)](https://doi.org/10.1109/18.119733) |
49
49
 
50
50
  ## Installation
51
51
 
@@ -57,12 +57,12 @@ pip install comdas
57
57
 
58
58
  ## Documentation
59
59
 
60
- Full documentation is available at [byron-selvage.github.io/ComDAS](https://byron-selvage.github.io/ComDAS/), including:
60
+ Full documentation is available at [dasdae.github.io/ComDAS](https://dasdae.github.io/ComDAS/), including:
61
61
 
62
- - [Quick start](https://byron-selvage.github.io/ComDAS/quickstart.html): compress a patch, save a compressed file, and read it back
63
- - [Codecs](https://byron-selvage.github.io/ComDAS/codecs/svd.html): configuration options and examples for each codec
64
- - [Examples](https://byron-selvage.github.io/ComDAS/examples/compression_comparison.html)
65
- - [API reference](https://byron-selvage.github.io/ComDAS/api/index.html)
62
+ - [Quick start](https://dasdae.github.io/ComDAS/quickstart.html): compress a patch, save a compressed file, and read it back
63
+ - [Codecs](https://dasdae.github.io/ComDAS/codecs/svd.html): configuration options and examples for each codec
64
+ - [Examples](https://dasdae.github.io/ComDAS/examples/compression_comparison.html)
65
+ - [API reference](https://dasdae.github.io/ComDAS/api/index.html)
66
66
 
67
67
  ## License
68
68
 
@@ -19,8 +19,8 @@ With ComDAS you can:
19
19
 
20
20
  | Codec | Main setting | Reference |
21
21
  | --- | --- | --- |
22
- | [SVD](https://byron-selvage.github.io/ComDAS/codecs/svd.html) | `rank` or retained `energy` | [NumPy SVD](https://numpy.org/doc/1.22/reference/generated/numpy.linalg.svd.html) |
23
- | [Wavelet](https://byron-selvage.github.io/ComDAS/codecs/wavelet.html) | `keep_fraction` or `threshold` | [PyWavelets](https://doi.org/10.21105/joss.01237); [DeVore et al. (1992)](https://doi.org/10.1109/18.119733) |
22
+ | [SVD](https://dasdae.github.io/ComDAS/codecs/svd.html) | `rank` or retained `energy` | [NumPy SVD](https://numpy.org/doc/1.22/reference/generated/numpy.linalg.svd.html) |
23
+ | [Wavelet](https://dasdae.github.io/ComDAS/codecs/wavelet.html) | `keep_fraction` or `threshold` | [PyWavelets](https://doi.org/10.21105/joss.01237); [DeVore et al. (1992)](https://doi.org/10.1109/18.119733) |
24
24
 
25
25
  ## Installation
26
26
 
@@ -32,12 +32,12 @@ pip install comdas
32
32
 
33
33
  ## Documentation
34
34
 
35
- Full documentation is available at [byron-selvage.github.io/ComDAS](https://byron-selvage.github.io/ComDAS/), including:
35
+ Full documentation is available at [dasdae.github.io/ComDAS](https://dasdae.github.io/ComDAS/), including:
36
36
 
37
- - [Quick start](https://byron-selvage.github.io/ComDAS/quickstart.html): compress a patch, save a compressed file, and read it back
38
- - [Codecs](https://byron-selvage.github.io/ComDAS/codecs/svd.html): configuration options and examples for each codec
39
- - [Examples](https://byron-selvage.github.io/ComDAS/examples/compression_comparison.html)
40
- - [API reference](https://byron-selvage.github.io/ComDAS/api/index.html)
37
+ - [Quick start](https://dasdae.github.io/ComDAS/quickstart.html): compress a patch, save a compressed file, and read it back
38
+ - [Codecs](https://dasdae.github.io/ComDAS/codecs/svd.html): configuration options and examples for each codec
39
+ - [Examples](https://dasdae.github.io/ComDAS/examples/compression_comparison.html)
40
+ - [API reference](https://dasdae.github.io/ComDAS/api/index.html)
41
41
 
42
42
  ## License
43
43
 
@@ -6,13 +6,80 @@ Patch data.
6
6
 
7
7
  from __future__ import annotations
8
8
 
9
- import itertools
10
-
11
9
  import numpy as np
12
10
 
13
11
  from comdas.codecs.base import Codec, CompressedPayload
14
12
 
15
13
 
14
+ def _materialize(obj):
15
+ """
16
+ Replace every :class:`DuckArray` in ``obj`` with a dense NumPy array,
17
+ recursing into lists, tuples, and dicts (e.g. the list passed to
18
+ ``np.concatenate``).
19
+
20
+ :param obj: An argument (or container of arguments) to a NumPy call.
21
+ :returns: ``obj`` with DuckArrays materialized.
22
+ """
23
+ if isinstance(obj, DuckArray):
24
+ return np.asarray(obj)
25
+ if type(obj) is list:
26
+ return [_materialize(o) for o in obj]
27
+ if type(obj) is tuple:
28
+ return tuple(_materialize(o) for o in obj)
29
+ if type(obj) is dict:
30
+ return {k: _materialize(v) for k, v in obj.items()}
31
+ return obj
32
+
33
+
34
+ def _densify_out(kwargs: dict) -> tuple[dict, list]:
35
+ """
36
+ Swap any DuckArray in an ``out=`` kwarg for a dense temporary buffer.
37
+
38
+ The buffer starts as a copy of the DuckArray's current values, so
39
+ elements a ``where=`` mask leaves untouched are preserved.
40
+
41
+ :param kwargs: Keyword arguments to a NumPy function/ufunc.
42
+ :returns: ``(new_kwargs, pairs)`` where ``pairs`` is a list of
43
+ ``(duck_array, dense_buffer)`` to pass to :func:`_write_back_out`.
44
+ """
45
+ out = kwargs.get("out")
46
+ if out is None:
47
+ return kwargs, []
48
+ is_tuple = isinstance(out, tuple)
49
+ pairs = []
50
+ new_out = []
51
+ for o in out if is_tuple else (out,):
52
+ if isinstance(o, DuckArray):
53
+ dense = np.asarray(o)
54
+ pairs.append((o, dense))
55
+ new_out.append(dense)
56
+ else:
57
+ new_out.append(o)
58
+ new_kwargs = dict(kwargs)
59
+ new_kwargs["out"] = tuple(new_out) if is_tuple else new_out[0]
60
+ return new_kwargs, pairs
61
+
62
+
63
+ def _write_back_out(result, pairs: list):
64
+ """
65
+ Fold dense ``out`` buffers back into their DuckArrays (re-encoding
66
+ them) and return those DuckArrays in place of the buffers.
67
+
68
+ :param result: The value returned by the NumPy call.
69
+ :param pairs: As returned by :func:`_densify_out`.
70
+ :returns: ``result``, with dense buffers replaced by their DuckArrays.
71
+ """
72
+ if not pairs:
73
+ return result
74
+ swap = {}
75
+ for duck, dense in pairs:
76
+ duck.consolidate(dense=dense)
77
+ swap[id(dense)] = duck
78
+ if isinstance(result, tuple):
79
+ return tuple(swap.get(id(r), r) for r in result)
80
+ return swap.get(id(result), result)
81
+
82
+
16
83
  class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
17
84
  """
18
85
  A NumPy-like duck array backed by a compressed payload and codec defining
@@ -131,8 +198,13 @@ class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
131
198
 
132
199
  def __array_function__(self, func, types, args, kwargs):
133
200
  """
134
- Apply a numpy function to this array. Decompresses the
135
- array before applying the NumPy function.
201
+ Apply a numpy function to this array. Decompresses any DuckArrays
202
+ among the arguments (including inside lists/tuples/dicts, e.g. for
203
+ ``np.concatenate``) before applying the NumPy function.
204
+
205
+ If a DuckArray is given as ``out=``, the result is computed into a
206
+ dense buffer and then re-encoded into that DuckArray (see
207
+ :meth:`consolidate`), which is returned in place of the buffer.
136
208
 
137
209
  NOTE: Some codecs support efficient operations directly
138
210
  on the compressed representation. We should provide a way
@@ -148,13 +220,19 @@ class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
148
220
  :param kwargs: Keyword arguments to ``func``.
149
221
  :returns: The result of calling ``func`` on materialized arrays.
150
222
  """
151
- args = [np.asarray(a) if isinstance(a, DuckArray) else a for a in args]
152
- return func(*args, **kwargs)
223
+ kwargs, out_pairs = _densify_out(kwargs)
224
+ result = func(*_materialize(args), **_materialize(kwargs))
225
+ return _write_back_out(result, out_pairs)
153
226
 
154
227
  def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
155
228
  """
156
229
  Materializes the array then applies the ufunc.
157
230
 
231
+ DuckArrays given as ``out=`` (including via in-place operators such
232
+ as ``+=``) or as the target of ``ufunc.at`` are updated by computing
233
+ into a dense buffer and re-encoding it (see :meth:`consolidate`).
234
+ Re-encoding with a lossy codec can introduce reconstruction error.
235
+
158
236
  NOTE: We should explore per-codec optimizations.
159
237
 
160
238
  :param ufunc: The NumPy ufunc being dispatched.
@@ -163,10 +241,20 @@ class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
163
241
  :param kwargs: Keyword arguments to the ufunc.
164
242
  :returns: The result of calling the ufunc on materialized arrays.
165
243
  """
166
- inputs = [
167
- np.asarray(i) if isinstance(i, DuckArray) else i for i in inputs
168
- ]
169
- return getattr(ufunc, method)(*inputs, **kwargs)
244
+ kwargs, out_pairs = _densify_out(kwargs)
245
+
246
+ # ufunc.at modifies its first input in place.
247
+ if method == "at" and isinstance(inputs[0], DuckArray):
248
+ target = inputs[0]
249
+ dense = np.asarray(target)
250
+ ufunc.at(dense, *_materialize(inputs[1:]), **_materialize(kwargs))
251
+ target.consolidate(dense=dense)
252
+ return None
253
+
254
+ result = getattr(ufunc, method)(
255
+ *_materialize(inputs), **_materialize(kwargs)
256
+ )
257
+ return _write_back_out(result, out_pairs)
170
258
 
171
259
  def __repr__(self) -> str:
172
260
  ratio = self._codec.compression_ratio(self._payload)
@@ -176,41 +264,25 @@ class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
176
264
  f"overlay={self.overlay_size})"
177
265
  )
178
266
 
179
- def _key_to_index_lists(self, key) -> list[list[int]]:
180
- """
181
- Normalize a NumPy-style key into per-axis integer index lists.
182
-
183
- :param key: A NumPy-style index/slice key.
184
- :returns: One list of concrete integer indices per axis.
185
- :rtype: list[list[int]]
267
+ def _selected_positions(self, key) -> tuple[np.ndarray, ...]:
186
268
  """
187
- if not isinstance(key, tuple):
188
- key = (key,)
189
- key = key + (slice(None),) * (self.ndim - len(key))
190
- axes: list[list[int]] = []
191
- for k, dim in zip(key, self.shape):
192
- if isinstance(k, (int, np.integer)):
193
- axes.append([int(k)])
194
- elif isinstance(k, slice):
195
- axes.append(list(range(*k.indices(dim))))
196
- else:
197
- axes.append(np.atleast_1d(np.asarray(k)).tolist())
198
- return axes
199
-
200
- @staticmethod
201
- def _normalize_key(key, ndim: int) -> tuple:
269
+ Find the physical position of every element selected by ``key``.
270
+
271
+ NumPy itself applies ``key`` (to zero-copy broadcast views of
272
+ per-axis index grids), so the result follows NumPy's indexing
273
+ semantics: negative indices, bounds checks, slices,
274
+ broadcast/paired advanced indices, ellipsis, newaxis, and boolean
275
+ masks.
276
+
277
+ :param key: A NumPy-style index key.
278
+ :returns: One integer array per axis, each with the shape of
279
+ ``dense[key]``, giving the coordinates of each
280
+ selected element.
281
+ :rtype: tuple[numpy.ndarray, ...]
282
+ :raises IndexError: If ``key`` is invalid for this array's shape.
202
283
  """
203
- Pad ``key`` with trailing full slices to length ``ndim``.
204
-
205
- :param key: A NumPy-style index/slice key.
206
- :param ndim: Number of dimensions to pad to.
207
- :type ndim: int
208
- :returns: The normalized, tuple-form key.
209
- :rtype: tuple
210
- """
211
- if not isinstance(key, tuple):
212
- key = (key,)
213
- return key + (slice(None),) * (ndim - len(key))
284
+ grids = np.indices(self.shape, sparse=True)
285
+ return tuple(np.broadcast_to(g, self.shape)[key] for g in grids)
214
286
 
215
287
  def __getitem__(self, key):
216
288
  """
@@ -223,28 +295,24 @@ class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
223
295
  if not self._overlay:
224
296
  return self._codec.decode_partial(self._payload, key)
225
297
 
226
- axes = self._key_to_index_lists(key)
227
- axes_sets = [set(a) for a in axes]
228
- touched = [
229
- (idx, val)
230
- for idx, val in self._overlay.items()
231
- if all(idx[d] in axes_sets[d] for d in range(self.ndim))
232
- ]
298
+ positions = self._selected_positions(key)
233
299
  base = np.array(
234
300
  self._codec.decode_partial(self._payload, key), copy=True
235
301
  )
236
- if not touched:
237
- return base
238
-
239
- norm_key = self._normalize_key(key, self.ndim)
240
- for idx, val in touched:
241
- local = tuple(
242
- axes[d].index(idx[d])
243
- for d, k in enumerate(norm_key)
244
- if not isinstance(k, (int, np.integer))
245
- )
246
- base[local] = val
247
- return base
302
+
303
+ # Match pending overlay writes to the selected elements by flat
304
+ # physical index.
305
+ selected = np.ravel_multi_index(positions, self.shape)
306
+ overlay_idx = np.ravel_multi_index(
307
+ tuple(np.array(list(self._overlay)).T), self.shape
308
+ )
309
+ overlay_vals = np.array(list(self._overlay.values()), dtype=self.dtype)
310
+ hit = np.isin(selected, overlay_idx)
311
+ if hit.any():
312
+ order = np.argsort(overlay_idx)
313
+ loc = np.searchsorted(overlay_idx, selected[hit], sorter=order)
314
+ base[hit] = overlay_vals[order[loc]]
315
+ return base[()] if base.ndim == 0 else base
248
316
 
249
317
  def __setitem__(self, key, value) -> None:
250
318
  """
@@ -277,10 +345,9 @@ class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
277
345
  except NotImplementedError:
278
346
  pass
279
347
 
280
- axes = self._key_to_index_lists(key)
281
- size = 1
282
- for a in axes:
283
- size *= len(a)
348
+ positions = self._selected_positions(key)
349
+ target_shape = positions[0].shape
350
+ size = positions[0].size
284
351
 
285
352
  if (
286
353
  size > self._max_pending_writes
@@ -291,10 +358,21 @@ class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
291
358
  self.consolidate(dense=dense)
292
359
  return
293
360
 
294
- indices = list(itertools.product(*axes))
295
- values = np.broadcast_to(value, tuple(len(a) for a in axes)).ravel()
296
- for idx, val in zip(indices, values):
297
- self._overlay[idx] = val
361
+ values = np.asarray(value).astype(self.dtype, copy=False)
362
+ # Like NumPy assignment, allow extra leading length-1 dims on value.
363
+ while values.ndim > len(target_shape) and values.shape[0] == 1:
364
+ values = values[0]
365
+ try:
366
+ values = np.broadcast_to(values, target_shape)
367
+ except ValueError:
368
+ raise ValueError(
369
+ f"could not broadcast input array from shape "
370
+ f"{np.shape(value)} into shape {target_shape}"
371
+ ) from None
372
+
373
+ flat_positions = [p.ravel() for p in positions]
374
+ for idx, val in zip(zip(*flat_positions), values.ravel()):
375
+ self._overlay[tuple(int(i) for i in idx)] = val
298
376
 
299
377
  def consolidate(
300
378
  self,
@@ -305,19 +383,22 @@ class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
305
383
  """
306
384
  Fold the write overlay into a freshly-compressed payload.
307
385
 
308
- By default, re-encodes using whatever compression level the
309
- current payload already used.
386
+ By default, re-encodes using whatever compression parameters the
387
+ current payload already used. Any ``encode_kwargs`` given override
388
+ those (see :meth:`~comdas.codecs.base.Codec.merge_compression_params`).
310
389
 
311
390
  :param dense: Precomputed dense array to encode, if the
312
391
  caller already has one.
313
392
  :type dense: numpy.ndarray or None
314
- :param encode_kwargs: Additional codec-specific kwargs
315
- forwarded to ``codec.encode``.
393
+ :param encode_kwargs: Codec-specific kwargs forwarded to
394
+ ``codec.encode``, overriding the current payload's parameters.
316
395
  """
317
396
  if dense is None:
318
397
  dense = self.__array__()
319
398
 
320
- encode_kwargs = self._payload.compression_params
399
+ params = self._codec.merge_compression_params(
400
+ self._payload.compression_params, encode_kwargs
401
+ )
321
402
 
322
- self._payload = self._codec.encode(dense, **encode_kwargs)
403
+ self._payload = self._codec.encode(dense, **params)
323
404
  self._overlay.clear()
@@ -70,11 +70,17 @@ class Codec(ABC):
70
70
  :cvar max_pending_writes: Maximum number of pending writes before automatic consolidation.
71
71
  This is only used by codecs that do not implement :meth:`partial_write`.
72
72
  :vartype max_pending_writes: int
73
+ :cvar exclusive_params: Names of mutually exclusive ``encode`` parameters
74
+ that select the compression mode (e.g. ``("rank", "energy")``). When
75
+ a caller overrides one of them, the others are dropped from the
76
+ payload's stored parameters. See :meth:`merge_compression_params`.
77
+ :vartype exclusive_params: tuple[str, ...]
73
78
  """
74
79
 
75
80
  name: str = ""
76
81
  version: str = "1"
77
82
  max_pending_writes: int = 10_000
83
+ exclusive_params: tuple[str, ...] = ()
78
84
 
79
85
  _registry: dict[str, type[Codec]] = {}
80
86
 
@@ -211,20 +217,55 @@ class Codec(ABC):
211
217
  :rtype: int
212
218
  """
213
219
 
220
+ def merge_compression_params(self, stored: dict, overrides: dict) -> dict:
221
+ """
222
+ Combine a payload's stored compression parameters with explicit
223
+ overrides, for re-encoding (e.g. in
224
+ :meth:`~comdas.arrays.duck_array.DuckArray.consolidate`).
225
+
226
+ Overrides win. If an override selects one of the mutually exclusive
227
+ :attr:`exclusive_params`, the other exclusive params are dropped
228
+ from ``stored`` so the old mode doesn't conflict with the new one.
229
+ Conflicts *within* ``overrides`` are left for :meth:`encode` to
230
+ reject.
231
+
232
+ :param stored: The payload's ``compression_params``.
233
+ :type stored: dict
234
+ :param overrides: Explicit keyword arguments from the caller.
235
+ :type overrides: dict
236
+ :returns: The parameters to pass to :meth:`encode`.
237
+ :rtype: dict
238
+ """
239
+ merged = dict(stored)
240
+ if any(k in self.exclusive_params for k in overrides):
241
+ for k in self.exclusive_params:
242
+ merged.pop(k, None)
243
+ merged.update(overrides)
244
+ return merged
245
+
214
246
  def compression_ratio(self, payload: CompressedPayload) -> float:
215
247
  """
216
- Helper to determine the compression ratio of the payload.
248
+ Helper to determine the compression ratio of the payload's stored
249
+ arrays.
250
+
251
+ This only accounts for the payload's arrays, not metadata or HDF5
252
+ container overhead, so it is not the on-disk file compression ratio.
217
253
 
218
254
  :param payload: A payload previously produced by :meth:`encode`.
219
255
  :type payload: CompressedPayload
220
- :returns: Original bytes divided by compressed bytes.
256
+ :returns: Original bytes divided by compressed bytes. ``inf`` if the
257
+ payload stores no bytes for a non-empty array, and ``nan`` if the
258
+ original array is empty too.
221
259
  :rtype: float
222
260
  """
223
261
  original_bytes = (
224
262
  int(np.prod(payload.original_shape))
225
263
  * np.dtype(payload.dtype).itemsize
226
264
  )
227
- return original_bytes / self.compressed_size_bytes(payload)
265
+ compressed_bytes = self.compressed_size_bytes(payload)
266
+ if compressed_bytes == 0:
267
+ return float("inf") if original_bytes > 0 else float("nan")
268
+ return original_bytes / compressed_bytes
228
269
 
229
270
  @abstractmethod
230
271
  def payload_to_group(self, payload: CompressedPayload, group) -> None:
@@ -78,6 +78,7 @@ class SVDCodec(Codec):
78
78
 
79
79
  # Writes are expensive with SVD
80
80
  max_pending_writes = 10_000
81
+ exclusive_params = ("rank", "energy")
81
82
 
82
83
  def __init__(self, rank: int | None = None, energy: float | None = None):
83
84
  if rank is not None and energy is not None:
@@ -166,26 +167,34 @@ class SVDCodec(Codec):
166
167
 
167
168
  :param payload: A payload previously produced by :meth:`encode`.
168
169
  :type payload: SVDPayload
169
- :param key: An int/slice index in the NumPy-style.
170
+ :param key: A NumPy-style index. Up to two ints/slices take the
171
+ fast path; anything else falls back to a full decode.
170
172
  :returns: The requested subset of the reconstructed array.
171
173
  :rtype: numpy.ndarray
174
+ :raises IndexError: If ``key`` is invalid for a 2D array (e.g. too
175
+ many indices or out of bounds), as with NumPy.
172
176
  """
173
177
  if not isinstance(key, tuple):
174
178
  key = (key,)
175
- key = key + (slice(None),) * (2 - len(key))
176
- row_key, col_key = key[0], key[1]
177
-
178
- # If using complex indexing (anything more than a simple int or slice)
179
- # Fall back to full decode
180
- if not (
181
- isinstance(row_key, (int, np.integer, slice))
182
- and isinstance(col_key, (int, np.integer, slice))
183
- ):
179
+
180
+ # Validate the key against the array shape using NumPy's rules.
181
+ np.broadcast_to(np.empty((), dtype=bool), payload.original_shape)[key]
182
+
183
+ def _is_simple(k) -> bool:
184
+ return isinstance(k, (int, np.integer, slice)) and not isinstance(
185
+ k, (bool, np.bool_)
186
+ )
187
+
188
+ # If using complex indexing (anything more than up to two simple
189
+ # ints/slices, e.g. arrays, ellipsis, newaxis) fall back to full decode
190
+ if len(key) > 2 or not all(_is_simple(k) for k in key):
184
191
  warn(
185
192
  "Only simple int/slice indexing is supported for partial decoding. Fully decoding entire array and then applying the index."
186
193
  )
187
194
  return self.decode(payload)[key]
188
195
 
196
+ key = key + (slice(None),) * (2 - len(key))
197
+ row_key, col_key = key
189
198
  row_is_int = isinstance(row_key, (int, np.integer))
190
199
  col_is_int = isinstance(col_key, (int, np.integer))
191
200
 
@@ -27,12 +27,16 @@ class WaveletPayload(CompressedPayload):
27
27
  :vartype indices: numpy.ndarray
28
28
  :ivar values: Values of retained coefficients.
29
29
  :vartype values: numpy.ndarray
30
+ :ivar threshold: The absolute threshold used to select coefficients, if
31
+ encoded in threshold mode. ``None`` if encoded with ``keep_fraction``.
32
+ :vartype threshold: float or None
30
33
  """
31
34
 
32
35
  wavelet: str
33
36
  mode: str
34
37
  indices: np.ndarray
35
38
  values: np.ndarray
39
+ threshold: float | None = None
36
40
 
37
41
  @property
38
42
  def coeff_shapes(self) -> list:
@@ -63,13 +67,18 @@ class WaveletPayload(CompressedPayload):
63
67
  """
64
68
  The compression parameters used for this payload.
65
69
 
70
+ Threshold-encoded payloads report their ``threshold``, so re-encoding
71
+ applies the same rule (a derived ``keep_fraction`` could be 0, which
72
+ isn't a valid input). Otherwise the retained ``keep_fraction``.
73
+
66
74
  :rtype: dict
67
75
  """
68
- return {
69
- "wavelet": self.wavelet,
70
- "mode": self.mode,
71
- "keep_fraction": self.values.size / self.n_coeffs,
72
- }
76
+ params = {"wavelet": self.wavelet, "mode": self.mode}
77
+ if self.threshold is not None:
78
+ params["threshold"] = self.threshold
79
+ else:
80
+ params["keep_fraction"] = self.values.size / self.n_coeffs
81
+ return params
73
82
 
74
83
 
75
84
  class WaveletCodec(Codec):
@@ -98,6 +107,7 @@ class WaveletCodec(Codec):
98
107
  """
99
108
 
100
109
  name = "WAVELET"
110
+ exclusive_params = ("keep_fraction", "threshold")
101
111
  version = "1"
102
112
 
103
113
  def __init__(
@@ -191,6 +201,7 @@ class WaveletCodec(Codec):
191
201
  mode=mode,
192
202
  indices=indices.astype(index_dtype),
193
203
  values=np.ascontiguousarray(flat[indices]),
204
+ threshold=float(threshold) if threshold is not None else None,
194
205
  )
195
206
 
196
207
  def decode(self, payload: WaveletPayload) -> np.ndarray:
@@ -244,6 +255,8 @@ class WaveletCodec(Codec):
244
255
  group.create_dataset("values", data=payload.values)
245
256
  group.attrs["wavelet"] = payload.wavelet
246
257
  group.attrs["mode"] = payload.mode
258
+ if payload.threshold is not None:
259
+ group.attrs["threshold"] = payload.threshold
247
260
 
248
261
  def payload_from_group(
249
262
  self, group, *, original_shape: tuple[int, ...], dtype: np.dtype
@@ -270,4 +283,9 @@ class WaveletCodec(Codec):
270
283
  mode=str(group.attrs["mode"]),
271
284
  indices=group["indices"][:],
272
285
  values=group["values"][:],
286
+ threshold=(
287
+ float(group.attrs["threshold"])
288
+ if "threshold" in group.attrs
289
+ else None
290
+ ),
273
291
  )