spectrochempy-hypercomplex 0.1.1__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.
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: spectrochempy-hypercomplex
3
+ Version: 0.1.1
4
+ Summary: Hypercomplex / quaternion support plugin for SpectroChemPy
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: spectrochempy<0.10,>=0.9.0.dev0
7
+ Requires-Dist: numpy
8
+ Requires-Dist: numpy-quaternion>=2024.0.7
@@ -0,0 +1,23 @@
1
+ [build-system]
2
+ requires = ["setuptools>=64"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "spectrochempy-hypercomplex"
7
+ version = "0.1.1"
8
+ description = "Hypercomplex / quaternion support plugin for SpectroChemPy"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "spectrochempy>=0.9.0.dev0,<0.10",
12
+ "numpy",
13
+ "numpy-quaternion>=2024.0.7",
14
+ ]
15
+
16
+ [project.entry-points."spectrochempy.plugins"]
17
+ hypercomplex = "spectrochempy_hypercomplex:HyperComplexPlugin"
18
+
19
+ [tool.setuptools.packages.find]
20
+ where = ["src"]
21
+
22
+ [tool.pytest.ini_options]
23
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,338 @@
1
+ # ruff: noqa: PLC0415 — defer imports in plugin methods to avoid startup cost
2
+ """Hypercomplex / quaternion support plugin for SpectroChemPy."""
3
+
4
+ from __future__ import annotations
5
+
6
+ from collections.abc import Callable
7
+
8
+ import numpy as np
9
+
10
+ from spectrochempy.api.plugins import CORE_PLUGIN_API_VERSION
11
+ from spectrochempy.api.plugins import PluginCapability
12
+ from spectrochempy.api.plugins import SpectroChemPyPlugin
13
+
14
+ # Public quaternion utilities — safe for other plugins to import
15
+ from ._quaternion import _HAS_QUATERNION as is_available # noqa: F401, N811
16
+ from ._quaternion import as_float_array # noqa: F401
17
+ from ._quaternion import as_quat_array # noqa: F401
18
+ from ._quaternion import as_quaternion # noqa: F401
19
+
20
+
21
+ class HyperAccessor:
22
+ """
23
+ Dataset accessor for hypercomplex / quaternion operations.
24
+
25
+ Accessed via ``dataset.hyper``.
26
+ """
27
+
28
+ def __init__(self, dataset) -> None:
29
+ self._dataset = dataset
30
+
31
+ # ------------------------------------------------------------------
32
+ # Properties
33
+ # ------------------------------------------------------------------
34
+
35
+ @property
36
+ def is_quaternion(self) -> bool:
37
+ """True if the data array is hypercomplex (quaternion)."""
38
+ from ._quaternion import typequaternion # noqa: PLC0415
39
+
40
+ return (
41
+ typequaternion is not None and self._dataset._data.dtype == typequaternion
42
+ )
43
+
44
+ @property
45
+ def RR(self):
46
+ """Real-real component of a 2D hypercomplex array."""
47
+ if not self.is_quaternion:
48
+ raise TypeError("Not a hypercomplex array")
49
+ return self.component("RR")
50
+
51
+ @property
52
+ def RI(self):
53
+ """Real-imaginary component of a 2D hypercomplex array."""
54
+ if not self.is_quaternion:
55
+ raise TypeError("Not a hypercomplex array")
56
+ return self.component("RI")
57
+
58
+ @property
59
+ def IR(self):
60
+ """Imaginary-real component of a 2D hypercomplex array."""
61
+ if not self.is_quaternion:
62
+ raise TypeError("Not a hypercomplex array")
63
+ return self.component("IR")
64
+
65
+ @property
66
+ def II(self):
67
+ """Imaginary-imaginary component of a 2D hypercomplex array."""
68
+ if not self.is_quaternion:
69
+ raise TypeError("Not a hypercomplex array")
70
+ return self.component("II")
71
+
72
+ # ------------------------------------------------------------------
73
+ # Methods
74
+ # ------------------------------------------------------------------
75
+
76
+ def set_quaternion(self, inplace=False):
77
+ """Convert the dataset data to quaternion (hypercomplex) type."""
78
+ from ._quaternion import typequaternion # noqa: PLC0415
79
+
80
+ if typequaternion is None:
81
+ raise RuntimeError(
82
+ "numpy-quaternion is required for hypercomplex support. "
83
+ "Install it with: pip install numpy-quaternion"
84
+ )
85
+
86
+ new = self._dataset if inplace else self._dataset.copy()
87
+ dtype = new._data.dtype if hasattr(new._data, "dtype") else new.dtype
88
+ if dtype != typequaternion:
89
+ new._data = _make_quaternion(new.data)
90
+ if not inplace:
91
+ return new
92
+ return None
93
+
94
+ set_hypercomplex = set_quaternion
95
+ set_hypercomplex.__doc__ = "Alias of set_quaternion."
96
+
97
+ def component(self, select="REAL"):
98
+ """
99
+ Extract a named component from a hypercomplex array.
100
+
101
+ Parameters
102
+ ----------
103
+ select : str
104
+ Component selector: ``"RR"``, ``"RI"``, ``"IR"``, ``"II"``,
105
+ or ``"REAL"`` / ``"R"`` / ``"I"`` for 1D complex data.
106
+ """
107
+ from ._quaternion import get_component # noqa: PLC0415
108
+
109
+ return get_component(self._dataset.data, select)
110
+
111
+
112
+ def _make_quaternion(data):
113
+ """Convert real/interleaved data to quaternion dtype."""
114
+ from ._quaternion import typequaternion # noqa: PLC0415
115
+
116
+ if data.ndim == 0:
117
+ return data
118
+ if typequaternion is not None and data.dtype == typequaternion:
119
+ return data
120
+ if data.ndim == 1:
121
+ r, i = data[::2], data[1::2]
122
+ return as_quaternion(r, i)
123
+ if data.ndim == 2:
124
+ return as_quaternion(data[:, ::2], data[:, 1::2])
125
+ msg = (
126
+ "An array of data to be transformed to quaternion must be 2D or 1D, "
127
+ f"got {data.ndim}D."
128
+ )
129
+ raise ValueError(msg)
130
+
131
+
132
+ # ------------------------------------------------------------------
133
+ # NDMath handler implementations
134
+ # ------------------------------------------------------------------
135
+
136
+
137
+ # Ufunc names that numpy-quaternion handles natively (no decomposition needed).
138
+ # Everything else must be decomposed into complex arrays, executed,
139
+ # and recomposed.
140
+ _NATIVE_QUATERNION_UFUNCS = frozenset(
141
+ [
142
+ "add",
143
+ "iadd",
144
+ "sub",
145
+ "isub",
146
+ "subtract",
147
+ "mul",
148
+ "imul",
149
+ "multiply",
150
+ "div",
151
+ "idiv",
152
+ "divide",
153
+ "true_divide",
154
+ "truediv",
155
+ "power",
156
+ "negative",
157
+ "positive",
158
+ "sign",
159
+ "equal",
160
+ "not_equal",
161
+ "less",
162
+ "less_equal",
163
+ "greater",
164
+ "greater_equal",
165
+ "isnan",
166
+ "isinf",
167
+ "isfinite",
168
+ "copysign",
169
+ "nextafter",
170
+ "spacing",
171
+ "maximum",
172
+ "minimum",
173
+ "fmax",
174
+ "fmin",
175
+ "absolute",
176
+ "fabs",
177
+ "conj",
178
+ "conjugate",
179
+ ]
180
+ )
181
+
182
+
183
+ def _hyper_ndmath_branch(fname: str, data: np.ndarray, args: list) -> str | None:
184
+ """Return ``"quaternion"`` when the operands require quaternion execution."""
185
+ from ._quaternion import typequaternion # noqa: PLC0415
186
+
187
+ if typequaternion is None:
188
+ return None
189
+
190
+ is_quaternion = data.dtype == typequaternion
191
+ for arg in args:
192
+ if hasattr(arg, "dtype") and arg.dtype == typequaternion:
193
+ is_quaternion = True
194
+ break
195
+
196
+ if not is_quaternion:
197
+ return None
198
+
199
+ # numpy-quaternion handles these ufuncs natively — no need to decompose.
200
+ if fname in _NATIVE_QUATERNION_UFUNCS:
201
+ return None
202
+
203
+ return "quaternion"
204
+
205
+
206
+ def _hyper_ndmath_execute(branch: str, f, d, args):
207
+ """Execute *f* on quaternion data *d* by decomposing into complex arrays."""
208
+ if branch != "quaternion":
209
+ return None
210
+ from ._quaternion import quat_as_complex_array # noqa: PLC0415
211
+
212
+ dr, di = quat_as_complex_array(d)
213
+ datar = f(dr, *args)
214
+ datai = f(di, *args)
215
+ return as_quaternion(datar, datai)
216
+
217
+
218
+ # ------------------------------------------------------------------
219
+ # Numpy-method overrides for quaternion data
220
+ # ------------------------------------------------------------------
221
+
222
+
223
+ def _is_quaternion_dataset(dataset) -> bool:
224
+ """Return True if *dataset* carries quaternion data."""
225
+ from ._quaternion import typequaternion # noqa: PLC0415
226
+
227
+ return (
228
+ typequaternion is not None
229
+ and hasattr(dataset, "_data")
230
+ and hasattr(dataset._data, "dtype")
231
+ and dataset._data.dtype == typequaternion
232
+ )
233
+
234
+
235
+ def _hyper_numpy_abs(dataset, *args, **kwargs):
236
+ """Quaternion-aware absolute value."""
237
+ if not _is_quaternion_dataset(dataset):
238
+ return None
239
+
240
+ w, x, y, z = as_float_array(dataset.data).T
241
+ data = np.ma.sqrt(w**2 + x**2 + y**2 + z**2)
242
+ dataset._data = data.data
243
+ dataset._mask = getattr(data, "mask", False)
244
+ return dataset
245
+
246
+
247
+ def _hyper_numpy_conjugate(dataset, *args, **kwargs):
248
+ """Quaternion-aware conjugate."""
249
+ if not _is_quaternion_dataset(dataset):
250
+ return None
251
+ dim = kwargs.get("dim", "x")
252
+ axis, _ = dataset.get_axis(dim, allows_none=True)
253
+ dataset = dataset.swapdims(axis, -1)
254
+ dataset[..., 1::2] = -dataset[..., 1::2]
255
+ return dataset.swapdims(axis, -1)
256
+
257
+
258
+ def _hyper_numpy_max(dataset, *args, **kwargs):
259
+ """Quaternion-aware max — operates on the real (w) component."""
260
+ if not _is_quaternion_dataset(dataset):
261
+ return None
262
+
263
+ data = dataset.data
264
+ w = as_float_array(data)[..., 0]
265
+ axis = kwargs.get("axis")
266
+ keepdims = kwargs.get("keepdims", False)
267
+ m = np.ma.max(w, axis=axis, keepdims=keepdims)
268
+ if np.isscalar(m) or (m.size == 1 and not keepdims):
269
+ if not np.isscalar(m):
270
+ m = m[()]
271
+ if dataset.units is not None:
272
+ from spectrochempy.core.units import Quantity # noqa: PLC0415
273
+
274
+ return Quantity(m, dataset.units)
275
+ return m
276
+ dataset._data = m.data
277
+ dataset._mask = getattr(m, "mask", False)
278
+ return dataset
279
+
280
+
281
+ def _hyper_numpy_min(dataset, *args, **kwargs):
282
+ """Quaternion-aware min — operates on the real (w) component."""
283
+ if not _is_quaternion_dataset(dataset):
284
+ return None
285
+
286
+ data = dataset.data
287
+ w = as_float_array(data)[..., 0]
288
+ axis = kwargs.get("axis")
289
+ keepdims = kwargs.get("keepdims", False)
290
+ m = np.ma.min(w, axis=axis, keepdims=keepdims)
291
+ if np.isscalar(m) or (m.size == 1 and not keepdims):
292
+ if not np.isscalar(m):
293
+ m = m[()]
294
+ if dataset.units is not None:
295
+ from spectrochempy.core.units import Quantity # noqa: PLC0415
296
+
297
+ return Quantity(m, dataset.units)
298
+ return m
299
+ dataset._data = m.data
300
+ dataset._mask = getattr(m, "mask", False)
301
+ return dataset
302
+
303
+
304
+ # ------------------------------------------------------------------
305
+ # Plugin class
306
+ # ------------------------------------------------------------------
307
+
308
+
309
+ class HyperComplexPlugin(SpectroChemPyPlugin):
310
+ """Hypercomplex / quaternion support for SpectroChemPy."""
311
+
312
+ name = "hypercomplex"
313
+ version = "0.1.0"
314
+ description = "Hypercomplex / quaternion data support"
315
+ spectrochempy_min_version = "0.9.0.dev0"
316
+ PLUGIN_API_VERSION = CORE_PLUGIN_API_VERSION
317
+ capabilities = [PluginCapability.ACCESSOR]
318
+
319
+ def register_accessors(self) -> list[dict]:
320
+ """Register the ``hyper`` dataset accessor."""
321
+ return [
322
+ {
323
+ "name": "hyper",
324
+ "func": HyperAccessor,
325
+ "description": "Hypercomplex / quaternion dataset operations",
326
+ },
327
+ ]
328
+
329
+ def register_handlers(self) -> dict[str, Callable]:
330
+ """Register NDMath execution handlers for quaternion dispatch."""
331
+ return {
332
+ "ndmath.execution_branch": _hyper_ndmath_branch,
333
+ "ndmath.execute": _hyper_ndmath_execute,
334
+ "ndmath.numpy_method.absolute": _hyper_numpy_abs,
335
+ "ndmath.numpy_method.conjugate": _hyper_numpy_conjugate,
336
+ "ndmath.numpy_method.amax": _hyper_numpy_max,
337
+ "ndmath.numpy_method.amin": _hyper_numpy_min,
338
+ }
@@ -0,0 +1,185 @@
1
+ # ======================================================================================
2
+ # Copyright (©) 2014-2026 Laboratoire Catalyse et Spectrochimie (LCS), Caen, France.
3
+ # CeCILL-B FREE SOFTWARE LICENSE AGREEMENT
4
+ # See full LICENSE agreement in the root directory.
5
+ # ======================================================================================
6
+ """
7
+ Optional quaternion support — internal to the hypercomplex plugin.
8
+
9
+ The public API is exposed through :class:`HyperAccessor`.
10
+ """
11
+
12
+ import warnings
13
+
14
+ import numpy as np
15
+
16
+ from spectrochempy.utils.constants import TYPE_COMPLEX
17
+
18
+ # Try to import the quaternion library (optional dependency)
19
+ _HAS_QUATERNION = False
20
+ typequaternion = None
21
+ as_float_array = None
22
+ as_quat_array = None
23
+
24
+ try:
25
+ from quaternion import as_float_array # noqa: F811
26
+ from quaternion import as_quat_array # noqa: F811
27
+
28
+ _HAS_QUATERNION = True
29
+ typequaternion = np.dtype(np.quaternion)
30
+ except ImportError:
31
+ pass
32
+
33
+
34
+ def _check():
35
+ if not _HAS_QUATERNION:
36
+ raise ImportError(
37
+ "Missing optional dependency 'numpy-quaternion'. "
38
+ "Use pip or conda to install numpy-quaternion."
39
+ )
40
+
41
+
42
+ # ======================================================================================
43
+ # Public methods
44
+ # ======================================================================================
45
+ def as_quaternion(*args):
46
+ """
47
+ Recombine the arguments to produce a numpy array with quaternion dtype.
48
+
49
+ Parameters
50
+ ----------
51
+ *args : `ndarray` with dtype: `float` or `complex`
52
+ The quaternion array components: If there is 4 components, then we assume it is
53
+ the four components of the
54
+ quaternion array: w, x, y, z. If there is only two, they are casted to complex
55
+ and correspond respectively
56
+ to w + i.x and y + j.z.
57
+
58
+ """
59
+ _check()
60
+ if len(args) == 4:
61
+ w, x, y, z = args
62
+ elif len(args) == 2:
63
+ r, i = args
64
+ w, x, y, z = r.real, r.imag, i.real, i.imag
65
+ else:
66
+ raise ValueError("as_quaternion requires 2 or 4 arguments")
67
+
68
+ data = as_quat_array(
69
+ list(zip(w.flatten(), x.flatten(), y.flatten(), z.flatten(), strict=False)),
70
+ )
71
+ return data.reshape(w.shape)
72
+
73
+
74
+ def quat_as_complex_array(arr):
75
+ """
76
+ Recombine the component of a quaternion array into a tuple of two complex array.
77
+
78
+ Parameters
79
+ ----------
80
+ arr : quaternion ndarray
81
+ The arr will be separated into (w + i.x) and (y + i.z).
82
+
83
+ Returns
84
+ -------
85
+ tuple
86
+ Tuple of two complex array.
87
+
88
+ """
89
+ _check()
90
+ if typequaternion is None or arr.dtype != typequaternion:
91
+ return arr
92
+
93
+ wt, xt, yt, zt = as_float_array(arr).T
94
+ w, x, y, z = wt.T, xt.T, yt.T, zt.T
95
+
96
+ return (w + 1j * x), (y + 1j * z)
97
+
98
+
99
+ def get_component(data, select="REAL"):
100
+ """
101
+ Take selected components of an hypercomplex array (RRR, RIR, ...).
102
+
103
+ Parameters
104
+ ----------
105
+ data : `ndarray`
106
+ Input data array.
107
+ select : `str`, optional, default: 'REAL'
108
+ If 'REAL', only real component in all dimensions will be selected.
109
+ Else a string must specify which real (R) or imaginary (I) component
110
+ has to be selected along a specific dimension. For instance,
111
+ a string such as 'RRI' for a 2D hypercomplex array indicated
112
+ that we take the real component in each dimension except the last
113
+ one, for which imaginary component is preferred.
114
+
115
+ Returns
116
+ -------
117
+ component
118
+ A component of the complex or hypercomplex array.
119
+
120
+ .. warning::
121
+ The definition is somewhat different from Bruker, as we order the component in
122
+ the order of the dimensions in
123
+ dataset:
124
+ e.g., for dims = ['y','x'], 'IR' means that the `y` component is imaginary
125
+ while the `x` is real.
126
+
127
+ """
128
+ if not select:
129
+ return data
130
+
131
+ new = data.copy()
132
+
133
+ if select == "REAL":
134
+ select = "R" * new.ndim
135
+
136
+ w = x = y = z = None
137
+
138
+ if typequaternion is not None and new.dtype == typequaternion:
139
+ w, x, y, z = as_float_array(new).T
140
+ w, x, y, z = w.T, x.T, y.T, z.T
141
+ if select == "R":
142
+ new = w + x * 1j
143
+ elif select == "I":
144
+ new = y + z * 1j
145
+ elif select == "RR":
146
+ new = w
147
+ elif select == "RI":
148
+ new = x
149
+ elif select == "IR":
150
+ new = y
151
+ elif select == "II":
152
+ new = z
153
+ else:
154
+ raise ValueError(
155
+ f"something wrong: cannot interpret `{select}` for hypercomplex (quaternion) data!",
156
+ )
157
+
158
+ elif new.dtype in TYPE_COMPLEX:
159
+ w, x = new.real, new.imag
160
+ if (select == "R") or (select == "RR"):
161
+ new = w
162
+ elif (select == "I") or (select == "RI"):
163
+ new = x
164
+ else:
165
+ raise ValueError(
166
+ f"something wrong: cannot interpret `{select}` for complex data!",
167
+ )
168
+ else:
169
+ warnings.warn(
170
+ f"No selection was performed because datasets with complex data have no "
171
+ f"`{select}` component. ",
172
+ stacklevel=2,
173
+ )
174
+
175
+ return new
176
+
177
+
178
+ def interleaved2complex(data):
179
+ """Make a complex array from interleaved data."""
180
+ return data[..., ::2] + 1j * data[..., 1::2]
181
+
182
+
183
+ def interleaved2quaternion(data):
184
+ """Make a complex array from interleaved data."""
185
+ return data[..., ::2] + 1j * data[..., 1::2]
@@ -0,0 +1,8 @@
1
+ Metadata-Version: 2.4
2
+ Name: spectrochempy-hypercomplex
3
+ Version: 0.1.1
4
+ Summary: Hypercomplex / quaternion support plugin for SpectroChemPy
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: spectrochempy<0.10,>=0.9.0.dev0
7
+ Requires-Dist: numpy
8
+ Requires-Dist: numpy-quaternion>=2024.0.7
@@ -0,0 +1,10 @@
1
+ pyproject.toml
2
+ src/spectrochempy_hypercomplex/__init__.py
3
+ src/spectrochempy_hypercomplex/_quaternion.py
4
+ src/spectrochempy_hypercomplex.egg-info/PKG-INFO
5
+ src/spectrochempy_hypercomplex.egg-info/SOURCES.txt
6
+ src/spectrochempy_hypercomplex.egg-info/dependency_links.txt
7
+ src/spectrochempy_hypercomplex.egg-info/entry_points.txt
8
+ src/spectrochempy_hypercomplex.egg-info/requires.txt
9
+ src/spectrochempy_hypercomplex.egg-info/top_level.txt
10
+ tests/test_hypercomplex.py
@@ -0,0 +1,2 @@
1
+ [spectrochempy.plugins]
2
+ hypercomplex = spectrochempy_hypercomplex:HyperComplexPlugin
@@ -0,0 +1,3 @@
1
+ spectrochempy<0.10,>=0.9.0.dev0
2
+ numpy
3
+ numpy-quaternion>=2024.0.7
@@ -0,0 +1,49 @@
1
+ # ruff: noqa: S101
2
+ import pytest
3
+
4
+
5
+ class TestHyperAccessor:
6
+ def test_accessor_registered(self):
7
+ import spectrochempy as scp
8
+
9
+ ds = scp.NDDataset([1.0, 2.0, 3.0])
10
+ assert hasattr(ds, "hyper")
11
+
12
+ def test_is_quaternion_false_for_real(self):
13
+ import spectrochempy as scp
14
+
15
+ ds = scp.NDDataset([1.0, 2.0, 3.0])
16
+ assert ds.hyper.is_quaternion is False
17
+
18
+ def test_set_quaternion_creates_quaternion_dtype(self):
19
+ import spectrochempy as scp
20
+
21
+ ds = scp.NDDataset([1.0, 2.0, 3.0, 4.0])
22
+ result = ds.hyper.set_quaternion()
23
+ # After conversion, the data should have a quaternion dtype
24
+ assert result._data.dtype.name == "quaternion"
25
+
26
+ def test_component_real_data_noop(self):
27
+ import spectrochempy as scp
28
+
29
+ ds = scp.NDDataset([1.0, 2.0, 3.0])
30
+ # For real data, get_component returns the array unchanged
31
+ result = ds.hyper.component("RR")
32
+ assert result is not None
33
+
34
+ def test_RR_RI_IR_II_raise_for_real(self):
35
+ import spectrochempy as scp
36
+
37
+ ds = scp.NDDataset([1.0, 2.0, 3.0])
38
+ for prop in ["RR", "RI", "IR", "II"]:
39
+ with pytest.raises(TypeError):
40
+ getattr(ds.hyper, prop)
41
+
42
+
43
+ class TestNDMathHandlers:
44
+ def test_handler_registration(self):
45
+ from spectrochempy.plugins import manager
46
+
47
+ registry = manager.plugin_manager.registry
48
+ assert registry.get_handler("ndmath.execution_branch") is not None
49
+ assert registry.get_handler("ndmath.execute") is not None