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/index.py
ADDED
|
@@ -0,0 +1,470 @@
|
|
|
1
|
+
"""Label indexes: where labels are on one axis.
|
|
2
|
+
|
|
3
|
+
An index answers two questions about one axis's labels: the position of
|
|
4
|
+
each wanted label (``get_indexer``), and the positions holding a range of
|
|
5
|
+
labels (``slice_indexer``). ``index_for`` inspects a label array once and
|
|
6
|
+
picks the implementation:
|
|
7
|
+
|
|
8
|
+
- ``GridIndex`` for exactly evenly spaced numbers, times, or durations, such
|
|
9
|
+
as gridded latitude, longitude, and time, or positions 1, 2, 3: answered
|
|
10
|
+
by arithmetic, with no sort and no arrays held;
|
|
11
|
+
- ``SortedIndex`` for other strictly increasing labels, searched directly
|
|
12
|
+
with ``np.searchsorted``;
|
|
13
|
+
- ``SortOnceIndex`` for the rest, searched through one sort made when the
|
|
14
|
+
index is built.
|
|
15
|
+
|
|
16
|
+
There is no hash index: a Python dict of a million labels is slow to build
|
|
17
|
+
and cannot be looked up vectorised, so fast hashing needs pandas or a
|
|
18
|
+
compiled kernel, and the sort-once index serves unsorted labels until then.
|
|
19
|
+
|
|
20
|
+
Every index keeps the exact label rule (``labelarray.labels``): a wanted
|
|
21
|
+
label matches only a label of the same kind that is exactly equal, so a
|
|
22
|
+
lookup is converted to the labels' type with ``exact_cast`` and never
|
|
23
|
+
rounded. The integer ``2**53 + 1`` does not match the float ``2**53``,
|
|
24
|
+
float64 0.1 does not match float32 0.1, a half-second does not match whole
|
|
25
|
+
seconds, and ``True`` does not match 1. Ranges compare exactly too: a range
|
|
26
|
+
bound is moved to the nearest label value inside the range, never rounded
|
|
27
|
+
to the nearest.
|
|
28
|
+
|
|
29
|
+
This module needs NumPy only.
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import math
|
|
35
|
+
import warnings
|
|
36
|
+
from collections.abc import Hashable
|
|
37
|
+
from fractions import Fraction
|
|
38
|
+
from typing import Any, Protocol, runtime_checkable
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
from labelarray.errors import LabelError
|
|
43
|
+
from labelarray.labels import KINDS, exact_cast, kind, missing, register_types
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@runtime_checkable
|
|
47
|
+
class LabelIndex(Protocol):
|
|
48
|
+
"""What every label index answers, under the exact label rule.
|
|
49
|
+
|
|
50
|
+
``kind`` is the labels' kind (``labelarray.labels.KINDS``, or a kind
|
|
51
|
+
registered with ``register_index``); ``size`` the number of labels;
|
|
52
|
+
``unique`` whether each label names one position (one missing label
|
|
53
|
+
counts as a label; two repeat); ``sorted`` whether the labels other than
|
|
54
|
+
missing ones strictly increase along the axis.
|
|
55
|
+
"""
|
|
56
|
+
|
|
57
|
+
kind: str
|
|
58
|
+
size: int
|
|
59
|
+
unique: bool
|
|
60
|
+
sorted: bool
|
|
61
|
+
|
|
62
|
+
def get_indexer(self, wanted: Any) -> np.ndarray:
|
|
63
|
+
"""The position of each wanted label, or -1 where it is absent; an error if labels repeat."""
|
|
64
|
+
...
|
|
65
|
+
|
|
66
|
+
def slice_indexer(self, start: Any = None, stop: Any = None) -> slice | np.ndarray:
|
|
67
|
+
"""The positions of labels from *start* to *stop*, both included, in axis order.
|
|
68
|
+
|
|
69
|
+
None leaves that end open. A ``slice`` where the positions are one
|
|
70
|
+
run, otherwise increasing positions. A missing label is never in a
|
|
71
|
+
range; a bound of another kind, or missing, is an error.
|
|
72
|
+
"""
|
|
73
|
+
...
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
# ── Searching ───────────────────────────────────────────────────────────────
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
class _SearchIndex:
|
|
80
|
+
"""Labels searched with ``np.searchsorted``, directly or through one sort.
|
|
81
|
+
|
|
82
|
+
A missing label (NaN or NaT) is held apart, since it never sorts or
|
|
83
|
+
compares equal; one such label can still be looked up.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
__slots__ = ("_kept", "_missing", "_sorter", "_values", "dtype", "kind", "size", "sorted", "unique")
|
|
87
|
+
|
|
88
|
+
_sort = True
|
|
89
|
+
|
|
90
|
+
def __init__(self, labels: Any, op: str = "index", dim: Hashable = None):
|
|
91
|
+
label_kind, values = kind(np.asarray(labels), op, dim)
|
|
92
|
+
self._build(label_kind, values, op, dim)
|
|
93
|
+
|
|
94
|
+
def _build(self, label_kind: str, values: np.ndarray, op: str, dim: Hashable) -> None:
|
|
95
|
+
gaps = missing(values)
|
|
96
|
+
kept = np.flatnonzero(~gaps)
|
|
97
|
+
present = values[kept] if gaps.any() else values
|
|
98
|
+
self.kind, self.dtype, self.size = label_kind, present.dtype, len(values)
|
|
99
|
+
self._missing = np.flatnonzero(gaps)
|
|
100
|
+
self._kept = None if len(kept) == len(values) else kept
|
|
101
|
+
try:
|
|
102
|
+
self.sorted = bool(np.all(present[1:] > present[:-1]))
|
|
103
|
+
if self.sorted or not self._sort:
|
|
104
|
+
self._sorter, self._values = None, present
|
|
105
|
+
else:
|
|
106
|
+
# Order among equal labels is irrelevant (repeats cannot be looked up), so the
|
|
107
|
+
# faster unstable sort is enough.
|
|
108
|
+
self._sorter = np.argsort(present)
|
|
109
|
+
self._values = present[self._sorter]
|
|
110
|
+
except TypeError:
|
|
111
|
+
raise LabelError(f"{op}: labels on {dim!r} cannot be ordered, so they cannot be looked up") from None
|
|
112
|
+
repeated = not self.sorted and bool(np.any(self._values[1:] == self._values[:-1]))
|
|
113
|
+
self.unique = not repeated and len(self._missing) <= 1
|
|
114
|
+
|
|
115
|
+
def get_indexer(self, wanted: Any) -> np.ndarray:
|
|
116
|
+
result, values = _lookup(self, wanted)
|
|
117
|
+
if values is None:
|
|
118
|
+
return result
|
|
119
|
+
gaps = missing(values)
|
|
120
|
+
if gaps.any() and len(self._missing):
|
|
121
|
+
result[gaps] = self._missing[0]
|
|
122
|
+
rest = np.flatnonzero(~gaps)
|
|
123
|
+
cast, exact = (values[rest], np.ones(len(rest), dtype=bool)) if values.dtype == self.dtype \
|
|
124
|
+
else exact_cast(values[rest], self.dtype)
|
|
125
|
+
if not len(self._values):
|
|
126
|
+
return result
|
|
127
|
+
found = np.searchsorted(self._values, cast)
|
|
128
|
+
clipped = np.minimum(found, len(self._values) - 1)
|
|
129
|
+
hit = exact & (found < len(self._values)) & (self._values[clipped] == cast)
|
|
130
|
+
positions = clipped if self._sorter is None else self._sorter[clipped]
|
|
131
|
+
if self._kept is not None:
|
|
132
|
+
positions = self._kept[positions]
|
|
133
|
+
result[rest[hit]] = positions[hit]
|
|
134
|
+
return result
|
|
135
|
+
|
|
136
|
+
def slice_indexer(self, start: Any = None, stop: Any = None) -> slice | np.ndarray:
|
|
137
|
+
first, last = 0, len(self._values)
|
|
138
|
+
if start is not None:
|
|
139
|
+
bound = _bound(self, start, up=True)
|
|
140
|
+
first = last if bound is None else int(np.searchsorted(self._values, bound, "left"))
|
|
141
|
+
if stop is not None:
|
|
142
|
+
bound = _bound(self, stop, up=False)
|
|
143
|
+
last = 0 if bound is None else int(np.searchsorted(self._values, bound, "right"))
|
|
144
|
+
last = max(first, last)
|
|
145
|
+
if self._sorter is None:
|
|
146
|
+
return slice(first, last) if self._kept is None else self._kept[first:last]
|
|
147
|
+
positions = np.sort(self._sorter[first:last])
|
|
148
|
+
return positions if self._kept is None else self._kept[positions]
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
class SortedIndex(_SearchIndex):
|
|
152
|
+
"""Strictly increasing labels (missing ones aside), searched directly: no sort, no copy."""
|
|
153
|
+
|
|
154
|
+
__slots__ = ()
|
|
155
|
+
|
|
156
|
+
_sort = False
|
|
157
|
+
|
|
158
|
+
def _build(self, label_kind: str, values: np.ndarray, op: str, dim: Hashable) -> None:
|
|
159
|
+
super()._build(label_kind, values, op, dim)
|
|
160
|
+
if not self.sorted:
|
|
161
|
+
raise LabelError(f"{op}: labels on {dim!r} are not strictly increasing; they need a sort-once index")
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
class SortOnceIndex(_SearchIndex):
|
|
165
|
+
"""Labels in any order, searched through one sort made when the index is built."""
|
|
166
|
+
|
|
167
|
+
__slots__ = ()
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
# ── A regular grid ──────────────────────────────────────────────────────────
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
class GridIndex:
|
|
174
|
+
"""Exactly evenly spaced numbers, times, or durations, increasing or decreasing.
|
|
175
|
+
|
|
176
|
+
Labels are held as a start, a step, and a count of integers, after
|
|
177
|
+
scaling floats by a power of two that makes every label an integer
|
|
178
|
+
(0.25 steps by 4), so lookups and ranges are integer arithmetic: exact,
|
|
179
|
+
with no sort and no arrays held. Labels are a grid only when every step
|
|
180
|
+
is exactly the same; ``np.arange(0, 1, 0.1)`` is not one, since its
|
|
181
|
+
labels are not exactly 0.1 apart in binary.
|
|
182
|
+
"""
|
|
183
|
+
|
|
184
|
+
__slots__ = ("_high", "_low", "_scale", "_start", "_step", "dtype", "kind", "size", "sorted", "unique")
|
|
185
|
+
|
|
186
|
+
def __init__(self, labels: Any, op: str = "index", dim: Hashable = None):
|
|
187
|
+
label_kind, values = kind(np.asarray(labels), op, dim)
|
|
188
|
+
if not self._build(label_kind, values):
|
|
189
|
+
raise LabelError(f"{op}: labels on {dim!r} are not evenly spaced; they need a search index")
|
|
190
|
+
|
|
191
|
+
@classmethod
|
|
192
|
+
def _of(cls, label_kind: str, values: np.ndarray) -> GridIndex | None:
|
|
193
|
+
index = object.__new__(cls)
|
|
194
|
+
return index if index._build(label_kind, values) else None
|
|
195
|
+
|
|
196
|
+
def _build(self, label_kind: str, values: np.ndarray) -> bool:
|
|
197
|
+
if len(values) < 2 or missing(values).any():
|
|
198
|
+
return False
|
|
199
|
+
domain = _integers(label_kind, values)
|
|
200
|
+
if domain is None:
|
|
201
|
+
return False
|
|
202
|
+
integers, scale = domain
|
|
203
|
+
start, step = int(integers[0]), int(integers[1]) - int(integers[0])
|
|
204
|
+
low, high = int(integers.min()), int(integers.max())
|
|
205
|
+
if step == 0 or high - low >= 2**63:
|
|
206
|
+
return False
|
|
207
|
+
if not np.all(np.diff(integers) == step): # no overflow: the span fits in int64
|
|
208
|
+
return False
|
|
209
|
+
self.kind, self.dtype, self.size = label_kind, values.dtype, len(values)
|
|
210
|
+
self.unique, self.sorted = True, step > 0
|
|
211
|
+
self._scale, self._start, self._step, self._low, self._high = scale, start, step, low, high
|
|
212
|
+
return True
|
|
213
|
+
|
|
214
|
+
def _label(self, integer: int) -> Any:
|
|
215
|
+
"""The label at *integer* in the scaled domain, as a scalar of the labels' type."""
|
|
216
|
+
if self.dtype.kind == "f":
|
|
217
|
+
return self.dtype.type(math.ldexp(integer, -self._scale))
|
|
218
|
+
if self.dtype.kind in "mM":
|
|
219
|
+
return np.array(integer, dtype=np.int64).view(self.dtype)[()]
|
|
220
|
+
return self.dtype.type(integer)
|
|
221
|
+
|
|
222
|
+
def get_indexer(self, wanted: Any) -> np.ndarray:
|
|
223
|
+
result, values = _lookup(self, wanted)
|
|
224
|
+
if values is None:
|
|
225
|
+
return result
|
|
226
|
+
rest = np.flatnonzero(~missing(values))
|
|
227
|
+
cast, exact = (values[rest], np.ones(len(rest), dtype=bool)) if values.dtype == self.dtype \
|
|
228
|
+
else exact_cast(values[rest], self.dtype)
|
|
229
|
+
with np.errstate(all="ignore"):
|
|
230
|
+
low, high = self._label(self._low), self._label(self._high)
|
|
231
|
+
inside = exact & (cast >= low) & (cast <= high)
|
|
232
|
+
cast = np.where(inside, cast, low) # outside values could overflow the integer domain
|
|
233
|
+
if self.dtype.kind == "f":
|
|
234
|
+
wide = cast.astype(np.float64)
|
|
235
|
+
scaled = np.ldexp(wide, self._scale)
|
|
236
|
+
# On the grid only if scaling lands on an integer and back on the value: a tiny
|
|
237
|
+
# value scaled down can round to an integer.
|
|
238
|
+
inside &= (scaled == np.floor(scaled)) & (np.ldexp(scaled, -self._scale) == wide)
|
|
239
|
+
integers = np.where(inside, scaled, self._low).astype(np.int64)
|
|
240
|
+
elif self.dtype.kind in "mM":
|
|
241
|
+
integers = cast.view(np.int64)
|
|
242
|
+
else:
|
|
243
|
+
integers = cast.astype(np.int64)
|
|
244
|
+
steps, remainder = np.divmod(integers - self._low, abs(self._step))
|
|
245
|
+
hit = inside & (remainder == 0)
|
|
246
|
+
positions = steps if self._step > 0 else self.size - 1 - steps
|
|
247
|
+
result[rest[hit]] = positions[hit]
|
|
248
|
+
return result
|
|
249
|
+
|
|
250
|
+
def slice_indexer(self, start: Any = None, stop: Any = None) -> slice:
|
|
251
|
+
empty = slice(0, 0)
|
|
252
|
+
# Each bound becomes the condition low <= label or label <= high on the scaled integers,
|
|
253
|
+
# as an exact fraction, and then a condition on positions.
|
|
254
|
+
low = high = None
|
|
255
|
+
if start is not None:
|
|
256
|
+
low = self._scaled(_bound(self, start, up=True), up=True)
|
|
257
|
+
if low is _NOTHING:
|
|
258
|
+
return empty
|
|
259
|
+
if stop is not None:
|
|
260
|
+
high = self._scaled(_bound(self, stop, up=False), up=False)
|
|
261
|
+
if high is _NOTHING:
|
|
262
|
+
return empty
|
|
263
|
+
if self._step < 0:
|
|
264
|
+
# Dividing by a negative step turns label >= low into an upper limit on positions.
|
|
265
|
+
low, high = high, low
|
|
266
|
+
first = 0 if low is None else max(0, math.ceil((low - self._start) / self._step))
|
|
267
|
+
last = self.size - 1 if high is None else min(self.size - 1, math.floor((high - self._start) / self._step))
|
|
268
|
+
return slice(first, last + 1) if first <= last else empty
|
|
269
|
+
|
|
270
|
+
def _scaled(self, bound: Any, up: bool) -> Any:
|
|
271
|
+
"""A bound in the scaled integer domain, None for no condition, or ``_NOTHING`` when no label qualifies."""
|
|
272
|
+
if bound is None:
|
|
273
|
+
return _NOTHING
|
|
274
|
+
if self.dtype.kind == "f":
|
|
275
|
+
if np.isinf(bound): # the grid holds no infinities
|
|
276
|
+
return None if (bound < 0) == up else _NOTHING
|
|
277
|
+
return Fraction(float(bound)) * Fraction(2) ** self._scale
|
|
278
|
+
if self.dtype.kind in "mM":
|
|
279
|
+
return int(np.array(bound).view(np.int64))
|
|
280
|
+
return int(bound)
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
# A range bound that no label can meet.
|
|
284
|
+
_NOTHING = object()
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _integers(label_kind: str, values: np.ndarray) -> tuple[np.ndarray, int] | None:
|
|
288
|
+
"""Labels as int64 integers and the power of two that scaled them there; None if they have none.
|
|
289
|
+
|
|
290
|
+
Times and durations are their int64 counts; units of varying length
|
|
291
|
+
(months, years) have no even spacing. Floats are scaled by the power of
|
|
292
|
+
two that makes the smallest bit of any label 1, if every label then fits.
|
|
293
|
+
"""
|
|
294
|
+
dtype = values.dtype
|
|
295
|
+
if label_kind in ("time", "duration"):
|
|
296
|
+
unit, _ = np.datetime_data(dtype)
|
|
297
|
+
return None if unit in ("Y", "M", "generic") else (values.view(np.int64), 0)
|
|
298
|
+
if label_kind != "number" or dtype.kind == "c":
|
|
299
|
+
return None
|
|
300
|
+
if dtype.kind in "iu":
|
|
301
|
+
if dtype == np.uint64 and values.max() > np.iinfo(np.int64).max:
|
|
302
|
+
return None
|
|
303
|
+
return values.astype(np.int64, copy=False), 0
|
|
304
|
+
if dtype.itemsize > 8:
|
|
305
|
+
return None
|
|
306
|
+
wide = values.astype(np.float64)
|
|
307
|
+
if not np.isfinite(wide).all():
|
|
308
|
+
return None
|
|
309
|
+
fraction, exponent = np.frexp(wide) # wide = fraction * 2**exponent, 0.5 <= |fraction| < 1
|
|
310
|
+
significand = np.ldexp(fraction, 53).astype(np.int64) # exact: float64 has 53 bits
|
|
311
|
+
nonzero = significand != 0
|
|
312
|
+
if not nonzero.any():
|
|
313
|
+
return None
|
|
314
|
+
lowest_bit = significand[nonzero] & -significand[nonzero]
|
|
315
|
+
_, bit_exponent = np.frexp(lowest_bit.astype(np.float64))
|
|
316
|
+
scale = -int((exponent[nonzero] - 53 + bit_exponent - 1).min())
|
|
317
|
+
if int(exponent[nonzero].max()) + scale > 62: # the labels would not fit in int64 with room for their span
|
|
318
|
+
return None
|
|
319
|
+
return np.ldexp(wide, scale).astype(np.int64), scale
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
# ── Lookups and bounds, shared ──────────────────────────────────────────────
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _lookup(index: Any, wanted: Any) -> tuple[np.ndarray, np.ndarray | None]:
|
|
326
|
+
"""An empty answer, and the wanted labels of the index's kind, or None when none can match."""
|
|
327
|
+
wanted = np.atleast_1d(np.asarray(wanted))
|
|
328
|
+
if wanted.ndim != 1:
|
|
329
|
+
raise LabelError("look up labels as a one-dimensional list")
|
|
330
|
+
result = np.full(len(wanted), -1, dtype=np.intp)
|
|
331
|
+
if not len(wanted) or not index.size:
|
|
332
|
+
return result, None
|
|
333
|
+
if not index.unique:
|
|
334
|
+
raise LabelError("labels repeat, so a label does not name one position")
|
|
335
|
+
return result, _of_kind(index.kind, wanted)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def _of_kind(label_kind: str, wanted: np.ndarray) -> np.ndarray | None:
|
|
339
|
+
"""*wanted* as labels of *label_kind*, with text read as times for time labels; None if of another kind."""
|
|
340
|
+
try:
|
|
341
|
+
wanted_kind, values = kind(wanted, "look up", None)
|
|
342
|
+
except LabelError:
|
|
343
|
+
return None # a label of a different type is simply not present
|
|
344
|
+
if label_kind == "time" and wanted_kind in ("text", "bytes"):
|
|
345
|
+
try:
|
|
346
|
+
values, wanted_kind = np.asarray(values, dtype="datetime64"), "time"
|
|
347
|
+
except ValueError:
|
|
348
|
+
return None
|
|
349
|
+
return values if wanted_kind == label_kind else None
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def _bound(index: Any, value: Any, up: bool) -> Any:
|
|
353
|
+
"""The labels' type's nearest value inside a range bound, so comparisons with it are exact.
|
|
354
|
+
|
|
355
|
+
*up* for a start: the least value at or above *value*; otherwise the
|
|
356
|
+
greatest at or below it. None when no value of the type qualifies.
|
|
357
|
+
"""
|
|
358
|
+
values = _of_kind(index.kind, np.atleast_1d(np.asarray(value, dtype=object if isinstance(value, str) else None)))
|
|
359
|
+
if values is None or values.shape != (1,):
|
|
360
|
+
raise LabelError(f"a range of {index.kind} labels cannot start or stop at {value!r}")
|
|
361
|
+
if missing(values)[0]:
|
|
362
|
+
raise LabelError("a range cannot start or stop at a missing label")
|
|
363
|
+
dtype = index.dtype
|
|
364
|
+
if dtype.kind == "c" or values.dtype.kind == "c":
|
|
365
|
+
raise LabelError("complex labels have no order, so they have no range")
|
|
366
|
+
if dtype.kind in "iu":
|
|
367
|
+
return _integer_bound(values[0].item(), dtype, up)
|
|
368
|
+
if dtype.kind == "f":
|
|
369
|
+
return _float_bound(values[0].item(), dtype, up)
|
|
370
|
+
if dtype.kind in "mM":
|
|
371
|
+
return _time_bound(values[0], dtype, up)
|
|
372
|
+
return values[0] # text, bytes, booleans, and registered kinds compare as they are
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _integer_bound(value: float, dtype: np.dtype, up: bool) -> Any:
|
|
376
|
+
info = np.iinfo(dtype)
|
|
377
|
+
if isinstance(value, float):
|
|
378
|
+
if math.isinf(value):
|
|
379
|
+
if (value > 0) == up:
|
|
380
|
+
return None # starting above every integer, or stopping below every one
|
|
381
|
+
value = info.min if up else info.max
|
|
382
|
+
else:
|
|
383
|
+
value = math.ceil(value) if up else math.floor(value) # exact for any float
|
|
384
|
+
if up:
|
|
385
|
+
return None if value > info.max else dtype.type(max(value, info.min))
|
|
386
|
+
return None if value < info.min else dtype.type(min(value, info.max))
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def _float_bound(value: float, dtype: np.dtype, up: bool) -> Any:
|
|
390
|
+
with np.errstate(over="ignore"), warnings.catch_warnings():
|
|
391
|
+
warnings.simplefilter("ignore", RuntimeWarning)
|
|
392
|
+
try:
|
|
393
|
+
bound = dtype.type(value) # rounded to nearest, so possibly on the wrong side
|
|
394
|
+
except OverflowError: # an integer beyond every float
|
|
395
|
+
bound = dtype.type(math.inf if value > 0 else -math.inf)
|
|
396
|
+
toward = dtype.type(math.inf if up else -math.inf)
|
|
397
|
+
# Python compares floats and integers exactly, so this steps over at most one float.
|
|
398
|
+
while (float(bound) < value) if up else (float(bound) > value):
|
|
399
|
+
bound = np.nextafter(bound, toward)
|
|
400
|
+
return bound
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def _time_bound(value: Any, dtype: np.dtype, up: bool) -> Any:
|
|
404
|
+
try:
|
|
405
|
+
with warnings.catch_warnings():
|
|
406
|
+
warnings.simplefilter("ignore")
|
|
407
|
+
bound = np.array(value).astype(dtype) # to a coarser unit NumPy rounds down
|
|
408
|
+
# NumPy compares times across units exactly, in the finer unit.
|
|
409
|
+
beyond = bool(bound < value) if up else bool(bound > value)
|
|
410
|
+
except (TypeError, ValueError): # durations in months or years have no fixed length
|
|
411
|
+
raise LabelError(f"{value!r} cannot be compared exactly with labels of type {dtype}") from None
|
|
412
|
+
count = int(bound.view(np.int64)) + (0 if not beyond else 1 if up else -1)
|
|
413
|
+
return np.array(count, dtype=np.int64).view(dtype)[()]
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
# ── Choosing an index ───────────────────────────────────────────────────────
|
|
417
|
+
|
|
418
|
+
_REGISTERED: dict[str, type] = {}
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def index_for(labels: Any, op: str = "index", dim: Hashable = None) -> LabelIndex:
|
|
422
|
+
"""The index for a label array, chosen by looking at the labels once.
|
|
423
|
+
|
|
424
|
+
A class registered for the labels' kind (``register_index``) is used
|
|
425
|
+
first; otherwise a grid index for evenly spaced numbers and times, a
|
|
426
|
+
sorted index for strictly increasing labels, and a sort-once index for
|
|
427
|
+
the rest. *op* and *dim* name the operation and axis in errors.
|
|
428
|
+
"""
|
|
429
|
+
label_kind, values = kind(np.asarray(labels), op, dim)
|
|
430
|
+
registered = _REGISTERED.get(label_kind)
|
|
431
|
+
if registered is not None:
|
|
432
|
+
return registered(labels, op, dim)
|
|
433
|
+
grid = GridIndex._of(label_kind, values)
|
|
434
|
+
if grid is not None:
|
|
435
|
+
return grid
|
|
436
|
+
index = object.__new__(SortOnceIndex)
|
|
437
|
+
index._build(label_kind, values, op, dim)
|
|
438
|
+
if index.sorted:
|
|
439
|
+
index.__class__ = SortedIndex # searched directly, as a sorted index is built
|
|
440
|
+
return index
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def register_index(label_kind: str, cls: type | None, *, types: tuple[type, ...] = ()) -> type | None:
|
|
444
|
+
"""Use *cls* for labels of *label_kind* from now on; return the class it replaces, or None.
|
|
445
|
+
|
|
446
|
+
*label_kind* is a built-in kind (``labelarray.labels.KINDS``), or a new
|
|
447
|
+
one whose labels are objects of *types*, so that labels of those types
|
|
448
|
+
are that kind wherever labels are compared. ``index_for`` calls
|
|
449
|
+
``cls(labels, op, dim)``; None removes the registration.
|
|
450
|
+
|
|
451
|
+
A registered class must follow the exact label rule, which no index may
|
|
452
|
+
loosen: give the index a ``kind``, match a wanted label only when it is
|
|
453
|
+
of that kind and exactly equal, converting lookups to the labels' type
|
|
454
|
+
with ``exact_cast`` and never by rounding or a tolerance, so that
|
|
455
|
+
``2**53 + 1`` never matches ``2**53``; compare range bounds exactly; and
|
|
456
|
+
raise ``LabelError`` for lookups when labels repeat. Labels of another
|
|
457
|
+
kind are simply absent.
|
|
458
|
+
"""
|
|
459
|
+
if cls is not None:
|
|
460
|
+
lacking = [name for name in ("get_indexer", "slice_indexer") if not callable(getattr(cls, name, None))]
|
|
461
|
+
if lacking:
|
|
462
|
+
raise TypeError(f"{cls.__name__} has no {' or '.join(lacking)}, which every label index answers")
|
|
463
|
+
previous = _REGISTERED.pop(label_kind, None)
|
|
464
|
+
if cls is not None:
|
|
465
|
+
_REGISTERED[label_kind] = cls
|
|
466
|
+
if label_kind not in KINDS.values():
|
|
467
|
+
register_types(label_kind, types if cls is not None else ())
|
|
468
|
+
elif types:
|
|
469
|
+
raise ValueError(f"{label_kind} is a built-in label kind; types name the labels of a new kind")
|
|
470
|
+
return previous
|