ComDAS 0.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.
comdas/__init__.py ADDED
@@ -0,0 +1,35 @@
1
+ """
2
+ ComDAS: compressed DAS patches for DASCore.
3
+
4
+ ComDAS patches work identically to normal ``dascore.Patch`` objects,
5
+ but the data they hold is stored compressed in memory
6
+ (:class:`~comdas.arrays.duck_array.DuckArray`) and
7
+ decompressed on access. Compression is controlled via
8
+ the :class:`~comdas.codecs.base.Codec` interface. Both the in-memory
9
+ array and the on-disk ``COMDAS`` container format
10
+ (:mod:`comdas.io.h5_container`) are codec-agnostic, working with any
11
+ registered codec by name rather than hardcoding a specific one.
12
+ """
13
+
14
+ from comdas.codecs.base import Codec, CompressedPayload
15
+ from comdas.codecs.svd import SVDCodec, SVDPayload
16
+ from comdas.codecs.wavelet import WaveletCodec, WaveletPayload
17
+ from comdas.arrays.duck_array import DuckArray
18
+
19
+ __all__ = [
20
+ "Codec",
21
+ "CompressedPayload",
22
+ "SVDCodec",
23
+ "SVDPayload",
24
+ "WaveletCodec",
25
+ "WaveletPayload",
26
+ "DuckArray",
27
+ ]
28
+
29
+ try:
30
+ from comdas.utils import compress_patch # requires dascore installed
31
+ from comdas.io import write_compressed # noqa: F401 -- also self-registers the COMDAS FiberIO plugin
32
+
33
+ __all__ += ["compress_patch", "write_compressed"]
34
+ except ImportError: # pragma: no cover
35
+ pass
@@ -0,0 +1,9 @@
1
+ """
2
+ Implements a NumPy-like array (`duck array
3
+ <https://docs.xarray.dev/en/stable/user-guide/duckarrays.html>`_) to hold
4
+ compressed Patch data.
5
+ """
6
+
7
+ from comdas.arrays.duck_array import DuckArray
8
+
9
+ __all__ = ["DuckArray"]
@@ -0,0 +1,323 @@
1
+ """
2
+ Implements a NumPy-like `duck array
3
+ <https://docs.xarray.dev/en/stable/user-guide/duckarrays.html>`_ for compressed
4
+ Patch data.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import itertools
10
+
11
+ import numpy as np
12
+
13
+ from comdas.codecs.base import Codec, CompressedPayload
14
+
15
+
16
+ class DuckArray(np.lib.mixins.NDArrayOperatorsMixin):
17
+ """
18
+ A NumPy-like duck array backed by a compressed payload and codec defining
19
+ how the data is compressed and decompressed.
20
+
21
+ Reads are performed via ``codec.decode_partial``. Writes go into a
22
+ sparse overlay dict rather than immediately forcing a complete recompression
23
+ of the underlying compressed payload. :meth:`consolidate` can be called to
24
+ merge the overlay into the compressed payload. :meth:`consolidate` will be
25
+ called automatically when the number of pending writes exceeds the codec's
26
+ specified maximum number of pending writes.
27
+
28
+ If the specified codec implements partial writes efficiently, the codec's
29
+ partial writes function will always be used to update the compressed
30
+ payload without accumulation.
31
+
32
+ :param payload: The compressed payload backing this duck array.
33
+ :type payload: CompressedPayload
34
+ :param codec: The codec defining how the data is compressed and decompressed.
35
+ :type codec: Codec
36
+ :param max_pending_writes: Maximum number of pending writes before automatic
37
+ consolidation.
38
+ :type max_pending_writes: int or None
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ payload: CompressedPayload,
44
+ codec: Codec,
45
+ *,
46
+ max_pending_writes: int | None = None,
47
+ ):
48
+ self._payload = payload
49
+ self._codec = codec
50
+ self._overlay: dict[tuple[int, ...], object] = {}
51
+ self._max_pending_writes = (
52
+ max_pending_writes
53
+ if max_pending_writes is not None
54
+ else codec.max_pending_writes
55
+ )
56
+
57
+ # Array basics
58
+ @property
59
+ def shape(self) -> tuple[int, ...]:
60
+ """
61
+ Get the shape of the decompressed/original array.
62
+
63
+ :rtype: tuple[int, ...]
64
+ """
65
+ return self._payload.original_shape
66
+
67
+ @property
68
+ def dtype(self) -> np.dtype:
69
+ """
70
+ Get the data type of the decompressed/original array.
71
+
72
+ :rtype: numpy.dtype
73
+ """
74
+ return self._payload.dtype
75
+
76
+ @property
77
+ def ndim(self) -> int:
78
+ """
79
+ Get the number of dimensions of the decompressed/original array.
80
+
81
+ :rtype: int
82
+ """
83
+ return len(self.shape)
84
+
85
+ def __len__(self) -> int:
86
+ return self.shape[0]
87
+
88
+ @property
89
+ def payload(self) -> CompressedPayload:
90
+ """
91
+ Get the current compressed payload backing this array.
92
+
93
+ :rtype: CompressedPayload
94
+ """
95
+ return self._payload
96
+
97
+ @property
98
+ def codec(self) -> Codec:
99
+ """
100
+ Get the codec used to encode/decode :attr:`payload`.
101
+
102
+ :rtype: Codec
103
+ """
104
+ return self._codec
105
+
106
+ @property
107
+ def overlay_size(self) -> int:
108
+ """
109
+ Get the number of elements currently held in the write overlay.
110
+
111
+ :rtype: int
112
+ """
113
+ return len(self._overlay)
114
+
115
+ def __array__(self, dtype=None) -> np.ndarray:
116
+ """
117
+ Fully materialize this array as a dense NumPy array.
118
+
119
+ :param dtype: Optional dtype to cast the result to.
120
+ :type dtype: numpy.dtype or None
121
+ :returns: The decompressed array, with any pending overlay
122
+ writes applied.
123
+ :rtype: numpy.ndarray
124
+ """
125
+ out = self._codec.decode(self._payload).copy()
126
+ for idx, val in self._overlay.items():
127
+ out[idx] = val
128
+ if dtype is not None:
129
+ out = out.astype(dtype, copy=False)
130
+ return out
131
+
132
+ def __array_function__(self, func, types, args, kwargs):
133
+ """
134
+ Apply a numpy function to this array. Decompresses the
135
+ array before applying the NumPy function.
136
+
137
+ NOTE: Some codecs support efficient operations directly
138
+ on the compressed representation. We should provide a way
139
+ to implement these in the future.
140
+
141
+ NOTE: Applying a chain of functions will decompress the array
142
+ at each step. We should optimize chained operations to avoid
143
+ this.
144
+
145
+ :param func: The NumPy function being dispatched.
146
+ :param types: Types of the arguments involved.
147
+ :param args: Positional arguments to ``func``.
148
+ :param kwargs: Keyword arguments to ``func``.
149
+ :returns: The result of calling ``func`` on materialized arrays.
150
+ """
151
+ args = [np.asarray(a) if isinstance(a, DuckArray) else a for a in args]
152
+ return func(*args, **kwargs)
153
+
154
+ def __array_ufunc__(self, ufunc, method, *inputs, **kwargs):
155
+ """
156
+ Materializes the array then applies the ufunc.
157
+
158
+ NOTE: We should explore per-codec optimizations.
159
+
160
+ :param ufunc: The NumPy ufunc being dispatched.
161
+ :param method: The ufunc method being called (e.g. ``"__call__"``).
162
+ :param inputs: Positional inputs to the ufunc.
163
+ :param kwargs: Keyword arguments to the ufunc.
164
+ :returns: The result of calling the ufunc on materialized arrays.
165
+ """
166
+ inputs = [
167
+ np.asarray(i) if isinstance(i, DuckArray) else i for i in inputs
168
+ ]
169
+ return getattr(ufunc, method)(*inputs, **kwargs)
170
+
171
+ def __repr__(self) -> str:
172
+ ratio = self._codec.compression_ratio(self._payload)
173
+ return (
174
+ f"DuckArray(shape={self.shape}, dtype={self.dtype}, "
175
+ f"codec={type(self._codec).__name__}, ratio={ratio:.1f}x, "
176
+ f"overlay={self.overlay_size})"
177
+ )
178
+
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]]
186
+ """
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:
202
+ """
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))
214
+
215
+ def __getitem__(self, key):
216
+ """
217
+ Read values from the array.
218
+
219
+ :param key: A NumPy-style index/slice key.
220
+ :returns: The requested subset of the array.
221
+ :rtype: numpy.ndarray or scalar
222
+ """
223
+ if not self._overlay:
224
+ return self._codec.decode_partial(self._payload, key)
225
+
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
+ ]
233
+ base = np.array(
234
+ self._codec.decode_partial(self._payload, key), copy=True
235
+ )
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
248
+
249
+ def __setitem__(self, key, value) -> None:
250
+ """
251
+ Write a value. Attempts to not decompress the entire array if possible.
252
+
253
+ Three strategies are tried, in order:
254
+
255
+ 1. If the codec implements :meth:`~comdas.codecs.base.Codec.partial_write`,
256
+ it's used directly. In this case, the payload is patched in place.
257
+ A codec may raise :class:`NotImplementedError` for a particular ``key``
258
+ it can't handle (e.g. it only supports single elements), in
259
+ this case it goes to strategy 2 for that call.
260
+ 2. Otherwise, small writes land in a sparse overlay dict,
261
+ applied on read and folded in by :meth:`consolidate`.
262
+ 3. A write that touches more than
263
+ :attr:`~comdas.codecs.base.Codec.max_pending_writes`
264
+ elements or would push the overlay size past it auto-consolidates
265
+ instead.
266
+
267
+ :param key: A NumPy-style index/slice key.
268
+ :param value: The value(s) to write; broadcast against the
269
+ shape implied by ``key``, matching normal NumPy assignment.
270
+ """
271
+ if self._codec.supports_partial_write():
272
+ try:
273
+ self._payload = self._codec.partial_write(
274
+ self._payload, key, value
275
+ )
276
+ return
277
+ except NotImplementedError:
278
+ pass
279
+
280
+ axes = self._key_to_index_lists(key)
281
+ size = 1
282
+ for a in axes:
283
+ size *= len(a)
284
+
285
+ if (
286
+ size > self._max_pending_writes
287
+ or (self.overlay_size + size) > self._max_pending_writes
288
+ ):
289
+ dense = self.__array__()
290
+ dense[key] = value
291
+ self.consolidate(dense=dense)
292
+ return
293
+
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
298
+
299
+ def consolidate(
300
+ self,
301
+ *,
302
+ dense: np.ndarray | None = None,
303
+ **encode_kwargs,
304
+ ) -> None:
305
+ """
306
+ Fold the write overlay into a freshly-compressed payload.
307
+
308
+ By default, re-encodes using whatever compression level the
309
+ current payload already used.
310
+
311
+ :param dense: Precomputed dense array to encode, if the
312
+ caller already has one.
313
+ :type dense: numpy.ndarray or None
314
+ :param encode_kwargs: Additional codec-specific kwargs
315
+ forwarded to ``codec.encode``.
316
+ """
317
+ if dense is None:
318
+ dense = self.__array__()
319
+
320
+ encode_kwargs = self._payload.compression_params
321
+
322
+ self._payload = self._codec.encode(dense, **encode_kwargs)
323
+ self._overlay.clear()
@@ -0,0 +1,14 @@
1
+ """Compression codec interfaces and implementations."""
2
+
3
+ from comdas.codecs.base import Codec, CompressedPayload
4
+ from comdas.codecs.svd import SVDCodec, SVDPayload
5
+ from comdas.codecs.wavelet import WaveletCodec, WaveletPayload
6
+
7
+ __all__ = [
8
+ "Codec",
9
+ "CompressedPayload",
10
+ "SVDCodec",
11
+ "SVDPayload",
12
+ "WaveletCodec",
13
+ "WaveletPayload",
14
+ ]
comdas/codecs/base.py ADDED
@@ -0,0 +1,264 @@
1
+ """
2
+ Base classes for compression codecs.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from abc import ABC, abstractmethod
8
+ from dataclasses import dataclass
9
+
10
+ import numpy as np
11
+
12
+
13
+ @dataclass
14
+ class CompressedPayload:
15
+ """
16
+ Base container for a codec's compressed payload (representation of an array).
17
+
18
+ Codecs should subclass this to hold the arrays/parameters the method requires.
19
+
20
+ :ivar original_shape: The shape of the array before compression.
21
+ :vartype original_shape: tuple[int, ...]
22
+ :ivar dtype: The data type of the array before compression.
23
+ :vartype dtype: numpy.dtype
24
+ """
25
+
26
+ original_shape: tuple[int, ...]
27
+ dtype: np.dtype
28
+
29
+ @property
30
+ def compression_params(self) -> dict:
31
+ """
32
+ The parameters used for compression.
33
+
34
+ The default implementation returns an empty dict. Codecs with
35
+ parameters relevant to re-encoding should override this.
36
+
37
+ :rtype: dict
38
+ """
39
+ return {}
40
+
41
+
42
+ class Codec(ABC):
43
+ """
44
+ Base class for all codecs.
45
+
46
+ A codec is responsible for compressing and decompressing arrays. It knows
47
+ how to turn a dense NumPy array into a :class:`CompressedPayload` and back,
48
+ and how to (de)serialize that payload to/from an HDF5 file.
49
+
50
+ Subclasses are auto-registered by name on class creation. This allows
51
+ the reader to automatically recognize and use the appropriate codec by name,
52
+ without having to import every codec.
53
+
54
+ Most codecs do not support random access writes and algorithms can be
55
+ inefficient at performing many small writes, so writes to the payload are
56
+ buffered until max_pending_writes is reached. Once this value is exceeded,
57
+ the entire array will be decompressed, the writes applied, and the
58
+ array recompressed. By default, this value is set to 10,000.
59
+
60
+ If a codec supports random access writes efficiently, it should
61
+ implement the :meth:`partial_write` method. If this method is
62
+ implemented, it will always be immediately used for applying writes,
63
+ bypassing max_pending_writes entirely.
64
+
65
+ :cvar name: Unique, human-readable name for the codec.
66
+ :vartype name: str
67
+ :cvar version: Codec format version, bumped if the codec's payload
68
+ structure changes in a way that is not backward-compatible.
69
+ :vartype version: str
70
+ :cvar max_pending_writes: Maximum number of pending writes before automatic consolidation.
71
+ This is only used by codecs that do not implement :meth:`partial_write`.
72
+ :vartype max_pending_writes: int
73
+ """
74
+
75
+ name: str = ""
76
+ version: str = "1"
77
+ max_pending_writes: int = 10_000
78
+
79
+ _registry: dict[str, type[Codec]] = {}
80
+
81
+ def __init_subclass__(cls, **kwargs) -> None:
82
+ """
83
+ Register ``cls`` in the registry under ``cls.name``.
84
+
85
+ :param kwargs: Forwarded to :meth:`object.__init_subclass__`.
86
+ :raises ValueError: If a concrete subclass doesn't set a
87
+ non-empty ``name``.
88
+ """
89
+ super().__init_subclass__(**kwargs)
90
+ if getattr(cls, "__abstractmethods__", None):
91
+ return
92
+ if not cls.name:
93
+ raise ValueError(
94
+ f"{cls.__name__} must define a `name` for codec registration."
95
+ )
96
+ Codec._registry[cls.name] = cls
97
+
98
+ @classmethod
99
+ def get_registered(cls, name: str) -> type[Codec]:
100
+ """
101
+ Look up a codec class by name.
102
+
103
+ :param name: The codec's ``name`` attribute.
104
+ :type name: str
105
+ :returns: The codec class registered under ``name``.
106
+ :rtype: type[Codec]
107
+ :raises KeyError: If no codec is registered under ``name``.
108
+ """
109
+ try:
110
+ return cls._registry[name]
111
+ except KeyError as err:
112
+ raise KeyError(
113
+ f"No codec registered under name {name!r}. Known codecs: {sorted(cls._registry)}"
114
+ ) from err
115
+
116
+ @abstractmethod
117
+ def encode(self, array: np.ndarray, **kwargs) -> CompressedPayload:
118
+ """
119
+ Compress ``array`` into a :class:`CompressedPayload`.
120
+
121
+ :param array: The dense array to compress.
122
+ :type array: numpy.ndarray
123
+ :param kwargs: Codec-specific compression parameters.
124
+ :returns: The compressed payload.
125
+ :rtype: CompressedPayload
126
+ """
127
+
128
+ @abstractmethod
129
+ def decode(self, payload: CompressedPayload) -> np.ndarray:
130
+ """
131
+ Fully decompress the payload.
132
+
133
+ :param payload: A payload previously produced by :meth:`encode`.
134
+ :type payload: CompressedPayload
135
+ :returns: The decompressed array.
136
+ :rtype: numpy.ndarray
137
+ """
138
+
139
+ def decode_partial(self, payload: CompressedPayload, key) -> np.ndarray:
140
+ """
141
+ Partially decompress the payload, reconstructing only the elements
142
+ addressed by ``key``.
143
+
144
+ The default implementation uses a full decode. Codecs that support
145
+ partial decompression or random access read/write should overwrite
146
+ this method.
147
+
148
+ :param payload: A payload previously produced by :meth:`encode`.
149
+ :type payload: CompressedPayload
150
+ :param key: A NumPy-style index/slice key.
151
+ :returns: The requested subset of the reconstructed array.
152
+ :rtype: numpy.ndarray
153
+ """
154
+ return self.decode(payload)[key]
155
+
156
+ def partial_write(
157
+ self, payload: CompressedPayload, key, value
158
+ ) -> CompressedPayload:
159
+ """
160
+ Write ``value`` into the compressed representation at ``key``,
161
+ without a full decode -> update -> re-encode round trip.
162
+
163
+ This is an optional method, most codecs do not support random access
164
+ writes. The default implementation always raises a
165
+ :class:`NotImplementedError`, which
166
+ :class:`~comdas.arrays.duck_array.DuckArray`
167
+ interprets as "this codec or write attempt doesn't
168
+ support true partial writes" and falls back to its overlay +
169
+ :attr:`default_overlay_flush_threshold` strategy instead.
170
+
171
+ A codec should implement this for the cases it can handle
172
+ cheaply and raise :class:`NotImplementedError` for cases it can't.
173
+ The fallback applies per call, not just
174
+ per codec. See :meth:`supports_partial_write`.
175
+
176
+ Implementations must apply exactly the same assignment
177
+ semantics as ``dense_array[key] = value`` would on the fully
178
+ decompressed array, including NumPy's broadcasting rules.
179
+
180
+ :param payload: The payload before the write.
181
+ :type payload: CompressedPayload
182
+ :param key: A NumPy-style index/slice key.
183
+ :param value: The value(s) to write, broadcast against the
184
+ shape implied by ``key`` exactly as plain NumPy assignment
185
+ would.
186
+ :returns: The payload reflecting the write.
187
+ :rtype: CompressedPayload
188
+ :raises NotImplementedError: Always, unless overridden.
189
+ """
190
+ raise NotImplementedError(
191
+ f"{type(self).__name__} does not support partial_write()."
192
+ )
193
+
194
+ def supports_partial_write(self) -> bool:
195
+ """
196
+ Whether this codec has overridden :meth:`partial_write`.
197
+
198
+ :returns: True if :meth:`partial_write` is overridden.
199
+ :rtype: bool
200
+ """
201
+ return type(self).partial_write is not Codec.partial_write
202
+
203
+ @abstractmethod
204
+ def compressed_size_bytes(self, payload: CompressedPayload) -> int:
205
+ """
206
+ Get the total size, in bytes, of everything stored in ``payload``.
207
+
208
+ :param payload: A payload previously produced by :meth:`encode`.
209
+ :type payload: CompressedPayload
210
+ :returns: Total size in bytes of the payload's stored arrays.
211
+ :rtype: int
212
+ """
213
+
214
+ def compression_ratio(self, payload: CompressedPayload) -> float:
215
+ """
216
+ Helper to determine the compression ratio of the payload.
217
+
218
+ :param payload: A payload previously produced by :meth:`encode`.
219
+ :type payload: CompressedPayload
220
+ :returns: Original bytes divided by compressed bytes.
221
+ :rtype: float
222
+ """
223
+ original_bytes = (
224
+ int(np.prod(payload.original_shape))
225
+ * np.dtype(payload.dtype).itemsize
226
+ )
227
+ return original_bytes / self.compressed_size_bytes(payload)
228
+
229
+ @abstractmethod
230
+ def payload_to_group(self, payload: CompressedPayload, group) -> None:
231
+ """
232
+ Write this codec's own arrays/params into an open HDF5 group.
233
+
234
+ Only codec-specific data belongs here. The general ``COMDAS``
235
+ container already writes ``original_shape``, ``dtype``,
236
+ ``codec_name``, and ``codec_version`` at the container level
237
+ before calling this. Don't duplicate those fields.
238
+
239
+ :param payload: The payload to serialize.
240
+ :type payload: CompressedPayload
241
+ :param group: An open, writable ``h5py.Group`` dedicated to
242
+ this one patch.
243
+ :type group: h5py.Group
244
+ """
245
+
246
+ @abstractmethod
247
+ def payload_from_group(
248
+ self, group, *, original_shape: tuple[int, ...], dtype: np.dtype
249
+ ) -> CompressedPayload:
250
+ """
251
+ Reconstruct this codec's payload from an HDF5 group.
252
+
253
+ :param group: An open, readable ``h5py.Group`` previously
254
+ written by :meth:`payload_to_group`.
255
+ :type group: h5py.Group
256
+ :param original_shape: The array shape, as already read from
257
+ the container's own (codec-independent) attributes.
258
+ :type original_shape: tuple[int, ...]
259
+ :param dtype: The array dtype, as already read from the
260
+ container's own (codec-independent) attributes.
261
+ :type dtype: numpy.dtype
262
+ :returns: The reconstructed payload.
263
+ :rtype: CompressedPayload
264
+ """