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/codecs/svd.py ADDED
@@ -0,0 +1,258 @@
1
+ """
2
+ Truncated Singular Value Decomposition (SVD) codec for 2D arrays.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+ from warnings import warn
9
+
10
+ import numpy as np
11
+
12
+ from comdas.codecs.base import Codec, CompressedPayload
13
+
14
+
15
+ @dataclass
16
+ class SVDPayload(CompressedPayload):
17
+ """
18
+ Truncated-SVD representation of a 2D array:
19
+ ``A ~= (U * s) @ Vt``.
20
+
21
+ :ivar U: Left singular vectors, shape ``(m, k)``.
22
+ :vartype U: numpy.ndarray
23
+ :ivar s: Singular values, shape ``(k,)``.
24
+ :vartype s: numpy.ndarray
25
+ :ivar Vt: Right singular vectors (transposed), shape ``(k, n)``.
26
+ :vartype Vt: numpy.ndarray
27
+ """
28
+
29
+ U: np.ndarray
30
+ s: np.ndarray
31
+ Vt: np.ndarray
32
+
33
+ @property
34
+ def rank(self) -> int:
35
+ """
36
+ The truncation rank ``k`` used on this payload.
37
+
38
+ :rtype: int
39
+ """
40
+ return self.s.shape[0]
41
+
42
+ @property
43
+ def compression_params(self) -> dict:
44
+ """
45
+ The compression parameters used for this payload.
46
+
47
+ :rtype: dict
48
+ """
49
+ return {
50
+ "rank": self.rank,
51
+ }
52
+
53
+
54
+ class SVDCodec(Codec):
55
+ """
56
+ Truncated-Singular Value Decomposition (SVD) codec for 2D arrays.
57
+
58
+ This codec compresses a 2D array by computing its truncated singular value
59
+ decomposition (SVD) and storing the left singular vectors, singular
60
+ values, and right singular vectors (transposed) as a :class:`SVDPayload`.
61
+
62
+ Truncation rank can be chosen either as a fixed integer ``rank`` or
63
+ as a retained energy fraction ``energy`` in ``(0,1]``. Exactly one option
64
+ must be specified, either as a codec-level default or per-call parameter.
65
+
66
+ SVD does not implement partial writes efficiently, so the full array is
67
+ re-encoded every 10,000 pending writes (or on demand).
68
+
69
+ :param rank: Default truncation rank ``k`` to use if not specified per-call.
70
+ :type rank: int | None
71
+ :param energy: Default retained energy fraction to use if not specified per-call.
72
+ :type energy: float | None
73
+ :raises ValueError: If both of ``rank`` and ``energy`` are specified.
74
+ """
75
+
76
+ name = "SVD"
77
+ version = "1"
78
+
79
+ # Writes are expensive with SVD
80
+ max_pending_writes = 10_000
81
+
82
+ def __init__(self, rank: int | None = None, energy: float | None = None):
83
+ if rank is not None and energy is not None:
84
+ raise ValueError(
85
+ "Provide at most one of rank / energy to act as codec default."
86
+ )
87
+ self.default_rank = rank
88
+ self.default_energy = energy
89
+
90
+ def encode(
91
+ self,
92
+ array: np.ndarray,
93
+ *,
94
+ rank: int | None = None,
95
+ energy: float | None = None,
96
+ **kwargs,
97
+ ) -> SVDPayload:
98
+ """
99
+ Compress a 2D array via truncated SVD.
100
+
101
+ :param array: The dense 2D array to compress.
102
+ :type array: numpy.ndarray
103
+ :param rank: Fixed truncation rank. Overrides
104
+ ``self.default_rank`` for this call.
105
+ :type rank: int or None
106
+ :param energy: Retained energy fraction in ``(0, 1]``.
107
+ Overrides ``self.default_energy`` for this call.
108
+ :type energy: float or None
109
+ :param kwargs: Accepted and ignored for compatibility.
110
+ :returns: The truncated-SVD payload.
111
+ :rtype: SVDPayload
112
+ :raises ValueError: If ``array`` is not 2D, or if both
113
+ ``rank`` and ``energy`` are specified.
114
+ """
115
+ array = np.asarray(array)
116
+ if array.ndim != 2:
117
+ raise ValueError(
118
+ f"SVDCodec only supports 2D arrays, got shape {array.shape}"
119
+ )
120
+
121
+ if rank is not None and energy is not None:
122
+ raise ValueError(
123
+ "Specify only one of `rank` or `energy` per call or use the codec default."
124
+ )
125
+ if rank is None and energy is None:
126
+ rank = self.default_rank
127
+ energy = self.default_energy
128
+ if (rank is None) == (energy is None):
129
+ raise ValueError(
130
+ "Exactly one of `rank` or `energy` must be specified, "
131
+ "either here or as a codec default."
132
+ )
133
+
134
+ U, s, Vt = np.linalg.svd(array, full_matrices=False)
135
+
136
+ if rank is None:
137
+ cumulative = np.cumsum(s**2)
138
+ cumulative /= cumulative[-1]
139
+ rank = int(np.searchsorted(cumulative, energy) + 1)
140
+ rank = min(rank, s.shape[0])
141
+ rank = max(1, min(rank, s.shape[0]))
142
+
143
+ return SVDPayload(
144
+ original_shape=array.shape,
145
+ dtype=array.dtype,
146
+ U=np.ascontiguousarray(U[:, :rank]),
147
+ s=np.ascontiguousarray(s[:rank]),
148
+ Vt=np.ascontiguousarray(Vt[:rank, :]),
149
+ )
150
+
151
+ def decode(self, payload: SVDPayload) -> np.ndarray:
152
+ """
153
+ Fully reconstruct the dense array as ``(U * s) @ Vt``.
154
+
155
+ :param payload: A payload previously produced by :meth:`encode`.
156
+ :type payload: SVDPayload
157
+ :returns: The reconstructed dense array.
158
+ :rtype: numpy.ndarray
159
+ """
160
+ out = (payload.U * payload.s) @ payload.Vt
161
+ return out.astype(payload.dtype, copy=False)
162
+
163
+ def decode_partial(self, payload: SVDPayload, key) -> np.ndarray:
164
+ """
165
+ Reconstruct only the requested rows/cols of ``payload``.
166
+
167
+ :param payload: A payload previously produced by :meth:`encode`.
168
+ :type payload: SVDPayload
169
+ :param key: An int/slice index in the NumPy-style.
170
+ :returns: The requested subset of the reconstructed array.
171
+ :rtype: numpy.ndarray
172
+ """
173
+ if not isinstance(key, tuple):
174
+ 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
+ ):
184
+ warn(
185
+ "Only simple int/slice indexing is supported for partial decoding. Fully decoding entire array and then applying the index."
186
+ )
187
+ return self.decode(payload)[key]
188
+
189
+ row_is_int = isinstance(row_key, (int, np.integer))
190
+ col_is_int = isinstance(col_key, (int, np.integer))
191
+
192
+ U_sub = payload.U[row_key, :]
193
+ Vt_sub = payload.Vt[:, col_key]
194
+ if row_is_int:
195
+ U_sub = U_sub[np.newaxis, :]
196
+ if col_is_int:
197
+ Vt_sub = Vt_sub[:, np.newaxis]
198
+
199
+ out = (U_sub * payload.s) @ Vt_sub
200
+ out = out.astype(payload.dtype, copy=False)
201
+
202
+ if row_is_int:
203
+ out = out[0]
204
+ if col_is_int:
205
+ out = out[..., 0]
206
+ return out
207
+
208
+ def compressed_size_bytes(self, payload: SVDPayload) -> int:
209
+ """
210
+ Get the total number of bytes used by ``U``, ``s``, and ``Vt``.
211
+
212
+ :param payload: A payload previously produced by :meth:`encode`.
213
+ :type payload: SVDPayload
214
+ :returns: Combined byte size of the three stored arrays.
215
+ :rtype: int
216
+ """
217
+ return payload.U.nbytes + payload.s.nbytes + payload.Vt.nbytes
218
+
219
+ def payload_to_group(self, payload: SVDPayload, group) -> None:
220
+ """
221
+ Write ``U``, ``s``, ``Vt``, and ``rank`` into an open ``h5py.Group``.
222
+
223
+ :param payload: The payload to serialize.
224
+ :type payload: SVDPayload
225
+ :param group: An open, writable ``h5py.Group`` dedicated to
226
+ this one patch.
227
+ :type group: h5py.Group
228
+ """
229
+ group.create_dataset("U", data=payload.U)
230
+ group.create_dataset("s", data=payload.s)
231
+ group.create_dataset("Vt", data=payload.Vt)
232
+ group.attrs["rank"] = payload.rank
233
+
234
+ def payload_from_group(
235
+ self, group, *, original_shape: tuple[int, ...], dtype: np.dtype
236
+ ) -> SVDPayload:
237
+ """
238
+ Construct an :class:`SVDPayload` from an HDF5 group.
239
+
240
+ :param group: An open, readable ``h5py.Group`` previously
241
+ written by :meth:`payload_to_group`.
242
+ :type group: h5py.Group
243
+ :param original_shape: The array shape, as read from the
244
+ container's own attributes.
245
+ :type original_shape: tuple[int, ...]
246
+ :param dtype: The array dtype, as read from the container's
247
+ own attributes.
248
+ :type dtype: numpy.dtype
249
+ :returns: The reconstructed payload.
250
+ :rtype: SVDPayload
251
+ """
252
+ return SVDPayload(
253
+ original_shape=original_shape,
254
+ dtype=dtype,
255
+ U=group["U"][:],
256
+ s=group["s"][:],
257
+ Vt=group["Vt"][:],
258
+ )
@@ -0,0 +1,273 @@
1
+ """
2
+ Wavelet compression via PyWavelets.
3
+ """
4
+
5
+ from __future__ import annotations
6
+
7
+ from dataclasses import dataclass
8
+
9
+ import numpy as np
10
+ import pywt
11
+
12
+ from comdas.codecs.base import Codec, CompressedPayload
13
+
14
+
15
+ @dataclass
16
+ class WaveletPayload(CompressedPayload):
17
+ """
18
+ Thresholded wavelet-coefficient representation of an array.
19
+
20
+ Retained coefficients are stored as ``(indices, values)`` pairs.
21
+
22
+ :ivar wavelet: Name of the PyWavelets wavelet.
23
+ :vartype wavelet: str
24
+ :ivar mode: PyWavelets signal extension mode.
25
+ :vartype mode: str
26
+ :ivar indices: Flat indices of retained coefficients.
27
+ :vartype indices: numpy.ndarray
28
+ :ivar values: Values of retained coefficients.
29
+ :vartype values: numpy.ndarray
30
+ """
31
+
32
+ wavelet: str
33
+ mode: str
34
+ indices: np.ndarray
35
+ values: np.ndarray
36
+
37
+ @property
38
+ def coeff_shapes(self) -> list:
39
+ """
40
+ Get the coefficient shapes of the decomposition before thresholding.
41
+
42
+ :rtype: list
43
+ """
44
+ return pywt.wavedecn_shapes(
45
+ self.original_shape, self.wavelet, mode=self.mode
46
+ )
47
+
48
+ @property
49
+ def n_coeffs(self) -> int:
50
+ """
51
+ Total number of coefficients in the full decomposition without thresholding.
52
+
53
+ :rtype: int
54
+ """
55
+ shapes = self.coeff_shapes
56
+ total = int(np.prod(shapes[0]))
57
+ for details in shapes[1:]:
58
+ total += sum(int(np.prod(s)) for s in details.values())
59
+ return total
60
+
61
+ @property
62
+ def compression_params(self) -> dict:
63
+ """
64
+ The compression parameters used for this payload.
65
+
66
+ :rtype: dict
67
+ """
68
+ return {
69
+ "wavelet": self.wavelet,
70
+ "mode": self.mode,
71
+ "keep_fraction": self.values.size / self.n_coeffs,
72
+ }
73
+
74
+
75
+ class WaveletCodec(Codec):
76
+ """
77
+ Wavelet-thresholding codec.
78
+
79
+ The array is decomposed with a multilevel n-dimensional discrete wavelet
80
+ transform (:func:`pywt.wavedecn`) at the maximum useful level for the
81
+ array shape, and only the largest-magnitude coefficients are retained.
82
+
83
+ Coefficients to keep can be chosen either as a fraction ``keep_fraction`` in
84
+ ``(0, 1]`` of all coefficients, or as an absolute magnitude
85
+ ``threshold`` where coefficients with ``|c| > threshold`` are kept. Exactly
86
+ one option must be specified, either as a codec-level default or
87
+ per-call parameter.
88
+
89
+ :param wavelet: PyWavelets wavelet name. (default: ``"db4"``)
90
+ :type wavelet: str
91
+ :param mode: PyWavelets signal extension mode. (default: ``"symmetric"``)
92
+ :type mode: str
93
+ :param keep_fraction: Default fraction of coefficients to retain.
94
+ :type keep_fraction: float | None
95
+ :param threshold: Default absolute coefficient magnitude threshold.
96
+ :type threshold: float | None
97
+ :raises ValueError: If both of ``keep_fraction`` and ``threshold`` are specified.
98
+ """
99
+
100
+ name = "WAVELET"
101
+ version = "1"
102
+
103
+ def __init__(
104
+ self,
105
+ wavelet: str = "db4",
106
+ mode: str = "symmetric",
107
+ keep_fraction: float | None = None,
108
+ threshold: float | None = None,
109
+ ):
110
+ if keep_fraction is not None and threshold is not None:
111
+ raise ValueError(
112
+ "Provide at most one of keep_fraction / threshold to act as codec default."
113
+ )
114
+ self.default_wavelet = wavelet
115
+ self.default_mode = mode
116
+ self.default_keep_fraction = keep_fraction
117
+ self.default_threshold = threshold
118
+
119
+ def encode(
120
+ self,
121
+ array: np.ndarray,
122
+ *,
123
+ wavelet: str | None = None,
124
+ mode: str | None = None,
125
+ keep_fraction: float | None = None,
126
+ threshold: float | None = None,
127
+ **kwargs,
128
+ ) -> WaveletPayload:
129
+ """
130
+ Compress an array via wavelet coefficient thresholding.
131
+
132
+ :param array: The dense array to compress.
133
+ :type array: numpy.ndarray
134
+ :param wavelet: Overrides ``self.default_wavelet`` for this call.
135
+ :type wavelet: str or None
136
+ :param mode: Overrides ``self.default_mode`` for this call.
137
+ :type mode: str or None
138
+ :param keep_fraction: Fraction of coefficients to retain, in ``(0, 1]``.
139
+ Overrides ``self.default_keep_fraction`` for this call.
140
+ :type keep_fraction: float or None
141
+ :param threshold: Absolute coefficient magnitude threshold.
142
+ Overrides ``self.default_threshold`` for this call.
143
+ :type threshold: float or None
144
+ :param kwargs: Accepted and ignored for compatibility.
145
+ :returns: The sparse wavelet payload.
146
+ :rtype: WaveletPayload
147
+ :raises ValueError: If both or neither of ``keep_fraction`` and
148
+ ``threshold`` are specified, or either is out of range.
149
+ """
150
+ array = np.asarray(array)
151
+ wavelet = wavelet if wavelet is not None else self.default_wavelet
152
+ mode = mode if mode is not None else self.default_mode
153
+
154
+ if keep_fraction is not None and threshold is not None:
155
+ raise ValueError(
156
+ "Specify only one of `keep_fraction` or `threshold` per call or use the codec default."
157
+ )
158
+ if keep_fraction is None and threshold is None:
159
+ keep_fraction = self.default_keep_fraction
160
+ threshold = self.default_threshold
161
+ if (keep_fraction is None) == (threshold is None):
162
+ raise ValueError(
163
+ "Exactly one of `keep_fraction` or `threshold` must be specified, "
164
+ "either here or as a codec default."
165
+ )
166
+ if keep_fraction is not None and not 0 < keep_fraction <= 1:
167
+ raise ValueError(
168
+ f"`keep_fraction` must be in (0, 1], got {keep_fraction}"
169
+ )
170
+ if threshold is not None and threshold < 0:
171
+ raise ValueError(f"`threshold` must be >= 0, got {threshold}")
172
+
173
+ coeffs = pywt.wavedecn(array, wavelet, mode=mode)
174
+ flat = pywt.ravel_coeffs(coeffs)[0]
175
+ magnitude = np.abs(flat)
176
+
177
+ if keep_fraction is not None:
178
+ k = max(1, min(flat.size, int(round(keep_fraction * flat.size))))
179
+ indices = np.argpartition(magnitude, -k)[-k:]
180
+ indices.sort()
181
+ else:
182
+ indices = np.flatnonzero(magnitude > threshold)
183
+
184
+ index_dtype = (
185
+ np.uint32 if flat.size <= np.iinfo(np.uint32).max else np.uint64
186
+ )
187
+ return WaveletPayload(
188
+ original_shape=array.shape,
189
+ dtype=array.dtype,
190
+ wavelet=wavelet,
191
+ mode=mode,
192
+ indices=indices.astype(index_dtype),
193
+ values=np.ascontiguousarray(flat[indices]),
194
+ )
195
+
196
+ def decode(self, payload: WaveletPayload) -> np.ndarray:
197
+ """
198
+ Reconstruct the dense array via the inverse wavelet transform.
199
+
200
+ :param payload: A payload previously produced by :meth:`encode`.
201
+ :type payload: WaveletPayload
202
+ :returns: The reconstructed dense array.
203
+ :rtype: numpy.ndarray
204
+ """
205
+ shapes = payload.coeff_shapes
206
+ template = [np.zeros(shapes[0])] + [
207
+ {key: np.zeros(shape) for key, shape in details.items()}
208
+ for details in shapes[1:]
209
+ ]
210
+ _, slices, coeff_shapes = pywt.ravel_coeffs(template)
211
+ flat = np.zeros(payload.n_coeffs, dtype=payload.values.dtype)
212
+ flat[payload.indices] = payload.values
213
+
214
+ coeffs = pywt.unravel_coeffs(
215
+ flat, slices, coeff_shapes, output_format="wavedecn"
216
+ )
217
+ out = pywt.waverecn(coeffs, payload.wavelet, mode=payload.mode)
218
+ out = out[tuple(slice(0, n) for n in payload.original_shape)]
219
+ return out.astype(payload.dtype, copy=False)
220
+
221
+ def compressed_size_bytes(self, payload: WaveletPayload) -> int:
222
+ """
223
+ Get payload size.
224
+
225
+ :param payload: A payload previously produced by :meth:`encode`.
226
+ :type payload: WaveletPayload
227
+ :returns: Combined byte size of the stored arrays.
228
+ :rtype: int
229
+ """
230
+ return payload.indices.nbytes + payload.values.nbytes
231
+
232
+ def payload_to_group(self, payload: WaveletPayload, group) -> None:
233
+ """
234
+ Write ``indices``, ``values``, and transform parameters into an
235
+ open ``h5py.Group``.
236
+
237
+ :param payload: The payload to serialize.
238
+ :type payload: WaveletPayload
239
+ :param group: An open, writable ``h5py.Group`` dedicated to
240
+ this one patch.
241
+ :type group: h5py.Group
242
+ """
243
+ group.create_dataset("indices", data=payload.indices)
244
+ group.create_dataset("values", data=payload.values)
245
+ group.attrs["wavelet"] = payload.wavelet
246
+ group.attrs["mode"] = payload.mode
247
+
248
+ def payload_from_group(
249
+ self, group, *, original_shape: tuple[int, ...], dtype: np.dtype
250
+ ) -> WaveletPayload:
251
+ """
252
+ Construct a :class:`WaveletPayload` from an HDF5 group.
253
+
254
+ :param group: An open, readable ``h5py.Group`` previously
255
+ written by :meth:`payload_to_group`.
256
+ :type group: h5py.Group
257
+ :param original_shape: The array shape, as read from the
258
+ container's own attributes.
259
+ :type original_shape: tuple[int, ...]
260
+ :param dtype: The array dtype, as read from the container's
261
+ own attributes.
262
+ :type dtype: numpy.dtype
263
+ :returns: The reconstructed payload.
264
+ :rtype: WaveletPayload
265
+ """
266
+ return WaveletPayload(
267
+ original_shape=original_shape,
268
+ dtype=dtype,
269
+ wavelet=str(group.attrs["wavelet"]),
270
+ mode=str(group.attrs["mode"]),
271
+ indices=group["indices"][:],
272
+ values=group["values"][:],
273
+ )
comdas/io/__init__.py ADDED
@@ -0,0 +1,18 @@
1
+ """
2
+ I/O support for ComDAS patches.
3
+
4
+ Importing this subpackage registers the ``COMDAS`` HDF5 container
5
+ format (:class:`~comdas.io.h5_container.ComdasV1`) with DASCore's
6
+ FiberIO manager, so ``dc.spool(path)`` / ``dc.read(...)`` / ``dc.write(...)``
7
+ work with it directly. :func:`~comdas.io.h5_container.write_compressed`
8
+ is a small convenience wrapper for the write side.
9
+ """
10
+
11
+ from comdas.io.h5_container import (
12
+ ComdasV1,
13
+ FORMAT_NAME,
14
+ FORMAT_VERSION,
15
+ write_compressed,
16
+ )
17
+
18
+ __all__ = ["ComdasV1", "FORMAT_NAME", "FORMAT_VERSION", "write_compressed"]