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/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