labelarray 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.
- labelarray/__init__.py +82 -0
- labelarray/__version__.py +1 -0
- labelarray/_compiled.py +101 -0
- labelarray/align.py +40 -0
- labelarray/array.py +474 -0
- labelarray/collection.py +267 -0
- labelarray/contract.py +47 -0
- labelarray/errors.py +21 -0
- labelarray/grouped.py +259 -0
- labelarray/index.py +470 -0
- labelarray/labels.py +244 -0
- labelarray/py.typed +0 -0
- labelarray/sums.py +317 -0
- labelarray/transforms.py +610 -0
- labelarray-0.1.0.dist-info/METADATA +187 -0
- labelarray-0.1.0.dist-info/RECORD +19 -0
- labelarray-0.1.0.dist-info/WHEEL +5 -0
- labelarray-0.1.0.dist-info/licenses/LICENSE +21 -0
- labelarray-0.1.0.dist-info/top_level.txt +1 -0
labelarray/array.py
ADDED
|
@@ -0,0 +1,474 @@
|
|
|
1
|
+
"""The labelled array.
|
|
2
|
+
|
|
3
|
+
An ``Array`` is a NumPy array with one name per axis, at most one label
|
|
4
|
+
array per axis, attributes, and a name. Its labels are its own: labels from
|
|
5
|
+
outside are copied once into a read-only ``_Labels`` array, which nothing
|
|
6
|
+
else can unlock, so each label array's index (``labelarray.index``) is built
|
|
7
|
+
on first use and kept on the array, released with it. Labels compare under
|
|
8
|
+
one exact rule (``labelarray.labels``). Values are not copied or frozen:
|
|
9
|
+
wrapping an array shares it.
|
|
10
|
+
|
|
11
|
+
NumPy's ufuncs, ``np.where``, and Python's operators broadcast by axis name
|
|
12
|
+
under the exact alignment rule (``labelarray.align``). Selection by
|
|
13
|
+
positions or labels, transposing, renaming axes, reductions (float32 sums
|
|
14
|
+
and means correctly rounded, ``labelarray.sums``), and contraction
|
|
15
|
+
(``dot``) are methods; ``concat``, ``align``, and ``broadcast`` work on
|
|
16
|
+
several arrays.
|
|
17
|
+
|
|
18
|
+
This module needs NumPy only.
|
|
19
|
+
"""
|
|
20
|
+
|
|
21
|
+
from __future__ import annotations
|
|
22
|
+
|
|
23
|
+
import builtins
|
|
24
|
+
import warnings
|
|
25
|
+
from collections.abc import Callable, Hashable, Mapping, Sequence
|
|
26
|
+
from typing import Any
|
|
27
|
+
|
|
28
|
+
import numpy as np
|
|
29
|
+
|
|
30
|
+
from labelarray import sums
|
|
31
|
+
from labelarray.align import order_like
|
|
32
|
+
from labelarray.contract import contract
|
|
33
|
+
from labelarray.errors import AlignError, LabelError, ShapeError
|
|
34
|
+
from labelarray.index import LabelIndex, index_for
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Array:
|
|
38
|
+
"""A NumPy array with one name per axis, labels for its labelled axes, attributes, and a name.
|
|
39
|
+
|
|
40
|
+
NumPy's ufuncs, ``np.where``, and Python's operators work on it, broadcasting by axis name under
|
|
41
|
+
the exact alignment rule (``labelarray.align``): the same labels in another order are matched by
|
|
42
|
+
label, and labels that differ are an ``AlignError``. Results have no attributes or name. Python's
|
|
43
|
+
operators do not warn about division by zero or overflow, as on xarray arrays; NumPy's functions do.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("attrs", "coords", "data", "dims", "name")
|
|
47
|
+
|
|
48
|
+
__hash__ = None # type: ignore[assignment] # == compares values, as on NumPy and xarray arrays
|
|
49
|
+
|
|
50
|
+
def __init__(self, data: Any, dims: Sequence[Hashable] = (), coords: Mapping[Hashable, Any] | None = None,
|
|
51
|
+
attrs: Mapping[Hashable, Any] | None = None, name: Hashable | None = None):
|
|
52
|
+
data = data if isinstance(data, np.ndarray) else np.asarray(data)
|
|
53
|
+
dims = (dims,) if isinstance(dims, str) else tuple(dims)
|
|
54
|
+
if len(dims) != data.ndim:
|
|
55
|
+
raise ShapeError(f"{len(dims)} axis names {dims} for an array of rank {data.ndim}")
|
|
56
|
+
if len(set(dims)) != len(dims):
|
|
57
|
+
raise ShapeError(f"axis names must be unique, got {dims}")
|
|
58
|
+
self.data, self.dims, self.name = data, dims, name
|
|
59
|
+
self.coords = _checked_coords(coords or {}, dict(zip(dims, data.shape)))
|
|
60
|
+
self.attrs = dict(attrs or {})
|
|
61
|
+
|
|
62
|
+
@classmethod
|
|
63
|
+
def _make(cls, data: np.ndarray, dims: tuple, coords: dict, attrs: dict | None = None,
|
|
64
|
+
name: Hashable | None = None) -> Array:
|
|
65
|
+
"""Build without checks, from parts already validated, with labels this library froze."""
|
|
66
|
+
array = object.__new__(cls)
|
|
67
|
+
array.data, array.dims, array.coords = data, dims, coords
|
|
68
|
+
array.attrs, array.name = attrs if attrs is not None else {}, name
|
|
69
|
+
return array
|
|
70
|
+
|
|
71
|
+
@property
|
|
72
|
+
def sizes(self) -> dict[Hashable, int]:
|
|
73
|
+
return dict(zip(self.dims, self.data.shape))
|
|
74
|
+
|
|
75
|
+
@property
|
|
76
|
+
def shape(self) -> tuple[int, ...]:
|
|
77
|
+
return self.data.shape
|
|
78
|
+
|
|
79
|
+
@property
|
|
80
|
+
def ndim(self) -> int:
|
|
81
|
+
return self.data.ndim
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def dtype(self) -> np.dtype:
|
|
85
|
+
return self.data.dtype
|
|
86
|
+
|
|
87
|
+
@property
|
|
88
|
+
def values(self) -> np.ndarray:
|
|
89
|
+
return self.data
|
|
90
|
+
|
|
91
|
+
def axis(self, dim: Hashable) -> int:
|
|
92
|
+
return self.dims.index(dim)
|
|
93
|
+
|
|
94
|
+
def labels(self, dim: Hashable) -> np.ndarray:
|
|
95
|
+
"""The labels of *dim* as a plain, read-only NumPy array."""
|
|
96
|
+
return plain(self.coords[dim])
|
|
97
|
+
|
|
98
|
+
def __repr__(self) -> str:
|
|
99
|
+
sizes = ", ".join(f"{d}: {n}" for d, n in self.sizes.items())
|
|
100
|
+
return f"<{type(self).__name__} {self.name or ''}({sizes}) {self.dtype}>"
|
|
101
|
+
|
|
102
|
+
# ── Selection and shape ─────────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
def isel(self, indexers: Mapping[Hashable, Any] | None = None, **more: Any) -> Array:
|
|
105
|
+
"""Positions, a slice, or a mask per axis; an integer removes the axis."""
|
|
106
|
+
array = self
|
|
107
|
+
for dim, index in {**(indexers or {}), **more}.items():
|
|
108
|
+
array = _take(array, dim, index)
|
|
109
|
+
return array
|
|
110
|
+
|
|
111
|
+
def sel(self, indexers: Mapping[Hashable, Any] | None = None, **more: Any) -> Array:
|
|
112
|
+
"""Labels per axis, looked up exactly; one label removes the axis, a list keeps it."""
|
|
113
|
+
wanted = {**(indexers or {}), **more}
|
|
114
|
+
return self.isel({dim: positions_of(self.coords[dim], labels, dim) for dim, labels in wanted.items()})
|
|
115
|
+
|
|
116
|
+
def transpose(self, *dims: Hashable) -> Array:
|
|
117
|
+
order = [self.axis(d) for d in dims]
|
|
118
|
+
return Array._make(np.transpose(self.data, order), tuple(dims), self.coords, self.attrs, self.name)
|
|
119
|
+
|
|
120
|
+
def rename(self, names: Mapping[Hashable, Hashable]) -> Array:
|
|
121
|
+
"""Axes renamed, labels with them."""
|
|
122
|
+
dims = tuple(names.get(d, d) for d in self.dims)
|
|
123
|
+
coords = {names.get(d, d): c for d, c in self.coords.items()}
|
|
124
|
+
return Array._make(self.data, dims, coords, self.attrs, self.name)
|
|
125
|
+
|
|
126
|
+
# ── Computation ─────────────────────────────────────────────────────────
|
|
127
|
+
|
|
128
|
+
def reduce(self, op: str, axes: Sequence[Hashable], *, na_rm: bool = False,
|
|
129
|
+
min_count: int | None = None) -> Array:
|
|
130
|
+
"""*op* (sum, mean, min, max, median, std, var, any, all, count) over *axes*.
|
|
131
|
+
|
|
132
|
+
Float32 sums and means are correctly rounded (``labelarray.sums``); other
|
|
133
|
+
reductions are NumPy's. ``na_rm`` skips missing values; a sum with
|
|
134
|
+
``min_count`` is missing where fewer values are present.
|
|
135
|
+
"""
|
|
136
|
+
axes = list(axes)
|
|
137
|
+
if not axes:
|
|
138
|
+
return self
|
|
139
|
+
numbers = tuple(self.axis(a) for a in axes)
|
|
140
|
+
if op in ("sum", "mean") and self.data.dtype == np.float32:
|
|
141
|
+
result = sums.reduce(self.data, op, numbers, na_rm=na_rm, min_count=min_count)
|
|
142
|
+
else:
|
|
143
|
+
# One axis goes to NumPy as an integer, as xarray passes it: NumPy sums a
|
|
144
|
+
# tuple of axes in another order, which changes float results in the last bit.
|
|
145
|
+
result = _reduce_data(self.data, op, numbers[0] if len(numbers) == 1 else numbers, na_rm, min_count)
|
|
146
|
+
dims = tuple(d for d in self.dims if d not in axes)
|
|
147
|
+
return Array._make(np.asarray(result), dims, {d: c for d, c in self.coords.items() if d in dims})
|
|
148
|
+
|
|
149
|
+
def dot(self, other: Array, over: Sequence[Hashable]) -> Array:
|
|
150
|
+
"""The sum of products over the axes *over*, as ``matmul`` computes it in any memory layout."""
|
|
151
|
+
result, kept = contract(self.data, self.dims, other.data, other.dims, over)
|
|
152
|
+
coords = {d: c for item in (self, other) for d, c in item.coords.items() if d in kept}
|
|
153
|
+
return Array._make(result, kept, coords)
|
|
154
|
+
|
|
155
|
+
# ── NumPy's protocols ───────────────────────────────────────────────────
|
|
156
|
+
|
|
157
|
+
def __array_ufunc__(self, ufunc: np.ufunc, method: str, *inputs: Any, **kwargs: Any) -> Any:
|
|
158
|
+
# Reductions, generalised ufuncs (core dimensions), and out= or where= arrays address
|
|
159
|
+
# positions, not axis names, so they are left to NumPy's error.
|
|
160
|
+
if method != "__call__" or ufunc.signature is not None or "out" in kwargs or "where" in kwargs:
|
|
161
|
+
return NotImplemented
|
|
162
|
+
if not all(isinstance(x, Array) or _is_scalar(x) for x in inputs):
|
|
163
|
+
return NotImplemented # a plain array has no axis names to broadcast by
|
|
164
|
+
return _elementwise(ufunc, inputs, kwargs)
|
|
165
|
+
|
|
166
|
+
def __array_function__(self, func: Callable, types: Sequence[type], args: Sequence[Any],
|
|
167
|
+
kwargs: Mapping[str, Any]) -> Any:
|
|
168
|
+
handler = _FUNCTIONS.get(func)
|
|
169
|
+
if handler is None or not all(issubclass(t, (Array, np.ndarray)) for t in types):
|
|
170
|
+
return NotImplemented
|
|
171
|
+
return handler(*args, **kwargs)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
# ── Labels ──────────────────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class _Labels(np.ndarray):
|
|
178
|
+
"""Labels this library owns: read-only, never shared writeable, with their index kept on them.
|
|
179
|
+
|
|
180
|
+
Views and slices are labels too, each with its own index. Arithmetic on
|
|
181
|
+
labels gives plain arrays, so labels never leak into values.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
_index: LabelIndex | None = None
|
|
185
|
+
|
|
186
|
+
def __array_finalize__(self, obj: Any) -> None:
|
|
187
|
+
self._index = None
|
|
188
|
+
|
|
189
|
+
def __array_ufunc__(self, ufunc: Any, method: str, *inputs: Any, **kwargs: Any) -> Any:
|
|
190
|
+
inputs = tuple(x.view(np.ndarray) if isinstance(x, _Labels) else x for x in inputs)
|
|
191
|
+
return getattr(ufunc, method)(*inputs, **kwargs)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def labels_from(labels: Any) -> np.ndarray:
|
|
195
|
+
"""Labels for an array, from values the caller made and does not keep: text is stored as objects."""
|
|
196
|
+
values = np.asarray(labels)
|
|
197
|
+
if values.dtype.kind in "UO":
|
|
198
|
+
values = values.astype(object)
|
|
199
|
+
return _freeze(values)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def plain(labels: np.ndarray) -> np.ndarray:
|
|
203
|
+
"""Labels as a plain NumPy array, without the index kept on them."""
|
|
204
|
+
return labels.view(np.ndarray)
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def label_index(labels: np.ndarray, op: str = "index", dim: Hashable = None) -> LabelIndex:
|
|
208
|
+
"""The index for a label array (``labelarray.index.index_for``): built on first use and kept on labels
|
|
209
|
+
this library owns.
|
|
210
|
+
|
|
211
|
+
Other arrays, which could change, get a fresh index each time.
|
|
212
|
+
"""
|
|
213
|
+
if not _owned(labels):
|
|
214
|
+
return index_for(labels, op, dim)
|
|
215
|
+
if labels._index is None:
|
|
216
|
+
labels._index = index_for(labels, op, dim)
|
|
217
|
+
return labels._index
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _checked_coords(coords: Mapping[Hashable, Any], sizes: Mapping[Hashable, int]) -> dict:
|
|
221
|
+
out = {}
|
|
222
|
+
for dim, labels in coords.items():
|
|
223
|
+
labels = _frozen_labels(labels)
|
|
224
|
+
if dim not in sizes:
|
|
225
|
+
raise ShapeError(f"labels for {dim}, which is not an axis; axes are {tuple(sizes)}")
|
|
226
|
+
if labels.ndim != 1 or len(labels) != sizes[dim]:
|
|
227
|
+
raise ShapeError(f"{dim} has {sizes[dim]} positions but {labels.size} labels")
|
|
228
|
+
out[dim] = labels
|
|
229
|
+
return out
|
|
230
|
+
|
|
231
|
+
|
|
232
|
+
def _owned(labels: Any) -> bool:
|
|
233
|
+
return isinstance(labels, _Labels) and not labels.flags.writeable
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _frozen_labels(labels: Any) -> _Labels:
|
|
237
|
+
"""Labels that can never change: this library's own are kept, others copied once."""
|
|
238
|
+
if _owned(labels):
|
|
239
|
+
return labels
|
|
240
|
+
return _freeze(np.array(labels, copy=True))
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _freeze(array: np.ndarray) -> _Labels:
|
|
244
|
+
"""Freeze a label array the caller just created and owns alone."""
|
|
245
|
+
if _owned(array):
|
|
246
|
+
return array
|
|
247
|
+
labels = array.view(_Labels)
|
|
248
|
+
labels.flags.writeable = False
|
|
249
|
+
return labels
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
# ── Several arrays ──────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
|
|
255
|
+
def broadcast(operands: Sequence[Any]) -> tuple[list[Any], tuple[Hashable, ...], dict]:
|
|
256
|
+
"""Raw values lined up on the union of named axes (first appearance), and the union's labels.
|
|
257
|
+
|
|
258
|
+
Labels are not compared here: callers align them first (``align``); sizes are checked.
|
|
259
|
+
Operands that are not arrays (scalars) are passed through.
|
|
260
|
+
"""
|
|
261
|
+
dims: list[Hashable] = []
|
|
262
|
+
sizes: dict[Hashable, int] = {}
|
|
263
|
+
coords: dict[Hashable, np.ndarray] = {}
|
|
264
|
+
for item in operands:
|
|
265
|
+
if isinstance(item, Array):
|
|
266
|
+
for dim, size in zip(item.dims, item.data.shape):
|
|
267
|
+
if dim not in sizes:
|
|
268
|
+
dims.append(dim)
|
|
269
|
+
sizes[dim] = size
|
|
270
|
+
elif sizes[dim] != size:
|
|
271
|
+
raise ShapeError(f"{dim} has {sizes[dim]} positions on one operand and {size} on another")
|
|
272
|
+
if dim in item.coords and dim not in coords:
|
|
273
|
+
coords[dim] = item.coords[dim]
|
|
274
|
+
raw = []
|
|
275
|
+
for item in operands:
|
|
276
|
+
if not isinstance(item, Array):
|
|
277
|
+
raw.append(item)
|
|
278
|
+
continue
|
|
279
|
+
present = [d for d in dims if d in item.dims]
|
|
280
|
+
data = item.data
|
|
281
|
+
if list(item.dims) != present:
|
|
282
|
+
data = np.transpose(data, [item.dims.index(d) for d in present])
|
|
283
|
+
if len(present) != len(dims):
|
|
284
|
+
data = np.reshape(data, [sizes[d] if d in item.dims else 1 for d in dims])
|
|
285
|
+
raw.append(data)
|
|
286
|
+
return raw, tuple(dims), coords
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def align(*operands: Any) -> list[Any]:
|
|
290
|
+
"""Each array's axes matched to the first array with that axis, under the exact rule.
|
|
291
|
+
|
|
292
|
+
The same labels in another order are put in the first array's order; an
|
|
293
|
+
axis labelled on one array and not on another, different labels, or
|
|
294
|
+
different sizes without labels are an ``AlignError``. Scalars are passed through.
|
|
295
|
+
"""
|
|
296
|
+
first: dict[Hashable, Array] = {}
|
|
297
|
+
aligned = []
|
|
298
|
+
for item in operands:
|
|
299
|
+
if isinstance(item, Array):
|
|
300
|
+
for dim in item.dims:
|
|
301
|
+
if dim in first:
|
|
302
|
+
item = _matched(first[dim], item, dim)
|
|
303
|
+
for dim in item.dims:
|
|
304
|
+
first.setdefault(dim, item)
|
|
305
|
+
aligned.append(item)
|
|
306
|
+
return aligned
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def concat(values: Sequence[Any], dim: Hashable, labels: Sequence[Any]) -> Array:
|
|
310
|
+
"""Arrays (or scalars) stacked along a new first axis *dim* with *labels*, broadcast by axis name."""
|
|
311
|
+
arrays = [v if isinstance(v, Array) else Array(v) for v in values]
|
|
312
|
+
raw, dims, coords = broadcast(arrays)
|
|
313
|
+
shape = np.broadcast_shapes(*(np.shape(r) for r in raw))
|
|
314
|
+
data = np.stack([np.broadcast_to(r, shape) for r in raw])
|
|
315
|
+
return Array._make(data, (dim, *dims), {dim: labels_from(labels), **coords})
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _matched(reference: Array, value: Array, dim: Hashable) -> Array:
|
|
319
|
+
labelled = dim in reference.coords
|
|
320
|
+
if labelled != (dim in value.coords):
|
|
321
|
+
raise AlignError(f"{dim} is labelled on one operand but not on another; "
|
|
322
|
+
"pairing by length could match the wrong positions")
|
|
323
|
+
if not labelled:
|
|
324
|
+
ours, theirs = reference.sizes[dim], value.sizes[dim]
|
|
325
|
+
if ours != theirs:
|
|
326
|
+
raise AlignError(f"{dim} has {ours:,} positions on one operand and {theirs:,} on another, "
|
|
327
|
+
"and neither has labels to match")
|
|
328
|
+
return value
|
|
329
|
+
order = order_like(reference.coords[dim], value.coords[dim], dim, index=label_index)
|
|
330
|
+
if order is None:
|
|
331
|
+
return value
|
|
332
|
+
taken = _take(value, dim, order)
|
|
333
|
+
taken.coords[dim] = reference.coords[dim]
|
|
334
|
+
return taken
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
# ── NumPy's protocols ───────────────────────────────────────────────────────
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
def _is_scalar(value: Any) -> bool:
|
|
341
|
+
return isinstance(value, (int, float, complex, str, bytes, np.generic)) or \
|
|
342
|
+
(isinstance(value, np.ndarray) and value.ndim == 0)
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _elementwise(func: Callable, operands: Sequence[Any], kwargs: Mapping[str, Any]) -> Any:
|
|
346
|
+
"""*func* on operands aligned and broadcast by axis name; one array per output."""
|
|
347
|
+
raw, dims, coords = broadcast(align(*operands))
|
|
348
|
+
result = func(*raw, **kwargs)
|
|
349
|
+
if isinstance(result, tuple):
|
|
350
|
+
return tuple(Array._make(np.asarray(r), dims, coords) for r in result)
|
|
351
|
+
return Array._make(np.asarray(result), dims, coords)
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def _where(condition: Any, true: Any = None, false: Any = None) -> Any:
|
|
355
|
+
operands = (condition, true, false)
|
|
356
|
+
if true is None or false is None or not all(isinstance(v, Array) or _is_scalar(v) for v in operands):
|
|
357
|
+
return NotImplemented # with one argument, np.where gives positions
|
|
358
|
+
return _elementwise(np.where, operands, {})
|
|
359
|
+
|
|
360
|
+
|
|
361
|
+
_FUNCTIONS: dict[Callable, Callable] = {np.where: _where}
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def _operator(ufunc: np.ufunc, reflected: bool = False) -> Callable:
|
|
365
|
+
def method(self: Array, other: Any) -> Any:
|
|
366
|
+
# Objects that set __array_ufunc__ to None (such as expression objects) build their own
|
|
367
|
+
# result through their reflected operators; anything else without axis names is not an operand.
|
|
368
|
+
if getattr(other, "__array_ufunc__", False) is None or not (isinstance(other, Array) or _is_scalar(other)):
|
|
369
|
+
return NotImplemented
|
|
370
|
+
with np.errstate(all="ignore"):
|
|
371
|
+
return _elementwise(ufunc, (other, self) if reflected else (self, other), {})
|
|
372
|
+
|
|
373
|
+
return method
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _unary(ufunc: np.ufunc) -> Callable:
|
|
377
|
+
def method(self: Array) -> Array:
|
|
378
|
+
with np.errstate(all="ignore"):
|
|
379
|
+
return _elementwise(ufunc, (self,), {})
|
|
380
|
+
|
|
381
|
+
return method
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
for _name, _ufunc in [
|
|
385
|
+
("add", np.add), ("sub", np.subtract), ("mul", np.multiply), ("truediv", np.true_divide),
|
|
386
|
+
("floordiv", np.floor_divide), ("mod", np.remainder), ("divmod", np.divmod), ("pow", np.power),
|
|
387
|
+
("and", np.bitwise_and), ("or", np.bitwise_or), ("xor", np.bitwise_xor),
|
|
388
|
+
("lshift", np.left_shift), ("rshift", np.right_shift),
|
|
389
|
+
]:
|
|
390
|
+
setattr(Array, f"__{_name}__", _operator(_ufunc))
|
|
391
|
+
setattr(Array, f"__r{_name}__", _operator(_ufunc, reflected=True))
|
|
392
|
+
for _name, _ufunc in [("lt", np.less), ("le", np.less_equal), ("eq", np.equal), ("ne", np.not_equal),
|
|
393
|
+
("gt", np.greater), ("ge", np.greater_equal)]:
|
|
394
|
+
setattr(Array, f"__{_name}__", _operator(_ufunc)) # Python swaps the sides of comparisons itself
|
|
395
|
+
for _name, _ufunc in [("neg", np.negative), ("pos", np.positive), ("abs", np.absolute), ("invert", np.invert)]:
|
|
396
|
+
setattr(Array, f"__{_name}__", _unary(_ufunc))
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
# ── Selection ───────────────────────────────────────────────────────────────
|
|
400
|
+
|
|
401
|
+
|
|
402
|
+
def _take(value: Array, dim: Hashable, index: Any) -> Array:
|
|
403
|
+
"""Positions, a slice, or a mask on one axis; an integer removes the axis."""
|
|
404
|
+
axis = value.axis(dim)
|
|
405
|
+
data = value.data[(builtins.slice(None),) * axis + (index,)]
|
|
406
|
+
coords = dict(value.coords)
|
|
407
|
+
if isinstance(index, (int, np.integer)):
|
|
408
|
+
coords.pop(dim, None)
|
|
409
|
+
dims = value.dims[:axis] + value.dims[axis + 1:]
|
|
410
|
+
else:
|
|
411
|
+
dims = value.dims
|
|
412
|
+
if dim in coords:
|
|
413
|
+
coords[dim] = _cut(coords[dim], index)
|
|
414
|
+
return Array._make(data, dims, coords, value.attrs, value.name)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def _cut(labels: np.ndarray, index: Any) -> np.ndarray:
|
|
418
|
+
"""Labels at positions, a slice, or a mask: a slice is a view of owned labels, the rest a new array."""
|
|
419
|
+
cut = labels[index]
|
|
420
|
+
return cut if isinstance(index, builtins.slice) else _freeze(cut)
|
|
421
|
+
|
|
422
|
+
|
|
423
|
+
def positions_of(labels: np.ndarray, wanted: Any, dim: Hashable) -> Any:
|
|
424
|
+
"""An integer for one label, positions for a list, as xarray's ``sel`` reads them."""
|
|
425
|
+
index = label_index(labels, "sel", dim)
|
|
426
|
+
many = not np.isscalar(wanted) and not (isinstance(wanted, np.ndarray) and wanted.ndim == 0)
|
|
427
|
+
found = index.get_indexer(np.atleast_1d(np.asarray(wanted, dtype=object if _is_text(wanted) else None)))
|
|
428
|
+
if (found < 0).any():
|
|
429
|
+
missing = np.atleast_1d(np.asarray(wanted, dtype=object))[found < 0][0]
|
|
430
|
+
raise LabelError(f"{dim} has no label {missing!r}")
|
|
431
|
+
return found if many else int(found[0])
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _is_text(value: Any) -> bool:
|
|
435
|
+
return isinstance(value, str) or (isinstance(value, (list, tuple)) and any(isinstance(v, str) for v in value))
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
# ── Reductions ──────────────────────────────────────────────────────────────
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
_REDUCERS = {
|
|
442
|
+
"sum": (np.sum, np.nansum), "mean": (np.mean, np.nanmean), "min": (np.min, np.nanmin),
|
|
443
|
+
"max": (np.max, np.nanmax), "median": (np.median, np.nanmedian), "std": (np.std, np.nanstd),
|
|
444
|
+
"var": (np.var, np.nanvar),
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
|
|
448
|
+
def _reduce_data(data: np.ndarray, op: str, axes: int | tuple[int, ...], na_rm: bool, min_count: int | None) -> Any:
|
|
449
|
+
"""NumPy reductions with missing values handled as xarray handles them."""
|
|
450
|
+
kind = data.dtype.kind
|
|
451
|
+
numbers = (axes,) if isinstance(axes, int) else axes
|
|
452
|
+
# Only some reductions need to know where values are missing; finding out reads the whole array.
|
|
453
|
+
needs_missing = op in ("any", "all", "count") or (op == "sum" and min_count is not None)
|
|
454
|
+
missing = None if not needs_missing else np.isnan(data) if kind in "fc" else np.isnat(data) if kind in "mM" else None
|
|
455
|
+
if op in ("min", "max") and builtins.any(data.shape[axis] == 0 for axis in numbers):
|
|
456
|
+
shape = tuple(n for i, n in enumerate(data.shape) if i not in numbers)
|
|
457
|
+
return np.full(shape, np.nan)
|
|
458
|
+
with warnings.catch_warnings(), np.errstate(all="ignore"):
|
|
459
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
460
|
+
if op in ("any", "all"):
|
|
461
|
+
filled = data if missing is None else np.where(missing, op == "all", data)
|
|
462
|
+
result = getattr(np, op)(filled, axis=axes)
|
|
463
|
+
if na_rm or kind in "biu" or missing is None:
|
|
464
|
+
return result
|
|
465
|
+
return np.where(~missing.any(axis=axes), result, np.nan) # missing wherever a value was
|
|
466
|
+
if op == "count":
|
|
467
|
+
present = ~missing if missing is not None else np.ones(data.shape, dtype=bool)
|
|
468
|
+
return present.sum(axis=axes)
|
|
469
|
+
skip = na_rm and kind in "fcmM"
|
|
470
|
+
result = _REDUCERS[op][1 if skip else 0](data, axis=axes)
|
|
471
|
+
if op == "sum" and min_count is not None:
|
|
472
|
+
present = ~missing if missing is not None else np.ones(data.shape, dtype=bool)
|
|
473
|
+
result = np.where(present.sum(axis=axes) >= min_count, result, np.nan)
|
|
474
|
+
return result
|