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/transforms.py
ADDED
|
@@ -0,0 +1,610 @@
|
|
|
1
|
+
"""Key transforms: what a key means, applied before positions are grouped.
|
|
2
|
+
|
|
3
|
+
Grouping itself is on exact equality only: positions share a group when
|
|
4
|
+
their keys are the same value, with fixed rules for floats (every NaN is
|
|
5
|
+
one missing key, and signed zeros are one value). Domain meaning lives here,
|
|
6
|
+
in a transform that turns key values into group codes and labels first:
|
|
7
|
+
ranges between edges (``Bins``), windows of time (``Duration``), numbers
|
|
8
|
+
rounded to a step (``Round``), and cells of a regular grid (``GridCells``).
|
|
9
|
+
|
|
10
|
+
The built-in transforms are small records holding their settings. Two
|
|
11
|
+
records are equal when they are the same class with equal settings, so a
|
|
12
|
+
record can key a cache of groupings; each prints its settings, and each
|
|
13
|
+
gives readable group labels in sorted order. A plain function is a
|
|
14
|
+
transform too (``as_transform``): it maps key values to keys, which are
|
|
15
|
+
then grouped exactly, and since functions cannot be compared it is equal to
|
|
16
|
+
nothing but itself.
|
|
17
|
+
|
|
18
|
+
Every transform puts a value on one side of each bucket edge, so values
|
|
19
|
+
either side of an edge fall into different groups however close they are.
|
|
20
|
+
That is by design, and the edges are the user's to place: bin edges, or a
|
|
21
|
+
step's or cell's ``origin``. Steps and origins are read as the decimals they
|
|
22
|
+
print as, so with a step of 0.1 the value 0.3 is in the cell printed
|
|
23
|
+
``[0.3, 0.4)``, although ``0.3 / 0.1`` is 2.9999999999999996 in floats.
|
|
24
|
+
|
|
25
|
+
This module needs NumPy only.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import datetime
|
|
31
|
+
import math
|
|
32
|
+
import re
|
|
33
|
+
import warnings
|
|
34
|
+
from collections.abc import Callable
|
|
35
|
+
from decimal import Decimal
|
|
36
|
+
from fractions import Fraction
|
|
37
|
+
from itertools import pairwise
|
|
38
|
+
from typing import Any
|
|
39
|
+
|
|
40
|
+
import numpy as np
|
|
41
|
+
|
|
42
|
+
from labelarray.errors import GroupError, ShapeError
|
|
43
|
+
from labelarray.labels import missing
|
|
44
|
+
|
|
45
|
+
# Messages about a transform's settings or values are clauses, meant to follow
|
|
46
|
+
# the call that made the transform ('bins(t, "2 days"): a duration needs time
|
|
47
|
+
# values'); ``outside`` and ``factorize`` give whole sentences.
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class Transform:
|
|
51
|
+
"""Base class for the built-in transforms.
|
|
52
|
+
|
|
53
|
+
``groups(values)`` takes key values none of which are missing (NaN or
|
|
54
|
+
NaT) and gives one code per value and the group labels in sorted order;
|
|
55
|
+
a value that belongs to no group (outside every bin, or infinite) gets
|
|
56
|
+
the code -1. ``factorize`` adds the missing group and refuses code -1.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
# Text labels ("[0, 15)") label the missing group with text too, so the
|
|
60
|
+
# labels stay of one type; number labels use NaN itself.
|
|
61
|
+
text_labels = True
|
|
62
|
+
|
|
63
|
+
def groups(self, values: np.ndarray) -> tuple[np.ndarray, list]:
|
|
64
|
+
raise NotImplementedError
|
|
65
|
+
|
|
66
|
+
def outside(self, count: int, name: str | None = None) -> str:
|
|
67
|
+
"""The message for *count* values that fall in no group."""
|
|
68
|
+
return f"{count:,} positions{_of(name)} fall in no group of {self!r}"
|
|
69
|
+
|
|
70
|
+
def missing_label(self, dtype: np.dtype) -> Any:
|
|
71
|
+
"""The label of the group of missing values, for key values of *dtype*."""
|
|
72
|
+
if self.text_labels:
|
|
73
|
+
return "NaT" if dtype.kind in "mM" else "NaN"
|
|
74
|
+
return math.nan
|
|
75
|
+
|
|
76
|
+
def _settings(self) -> tuple:
|
|
77
|
+
raise NotImplementedError
|
|
78
|
+
|
|
79
|
+
def __eq__(self, other: object) -> bool:
|
|
80
|
+
if type(other) is not type(self):
|
|
81
|
+
return NotImplemented
|
|
82
|
+
return self._settings() == other._settings()
|
|
83
|
+
|
|
84
|
+
def __hash__(self) -> int:
|
|
85
|
+
return hash((type(self).__name__, self._settings()))
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
class Function:
|
|
89
|
+
"""A plain function as a transform: it maps key values to keys, which are grouped exactly.
|
|
90
|
+
|
|
91
|
+
Functions cannot be compared for what they compute, so a ``Function``
|
|
92
|
+
equals only itself, and a grouping by one is never taken for another's.
|
|
93
|
+
"""
|
|
94
|
+
|
|
95
|
+
text_labels = False
|
|
96
|
+
|
|
97
|
+
def __init__(self, function: Callable[[np.ndarray], Any]):
|
|
98
|
+
self.function = function
|
|
99
|
+
|
|
100
|
+
def __repr__(self) -> str:
|
|
101
|
+
return f"Function({getattr(self.function, '__qualname__', repr(self.function))})"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def as_transform(transform: Any) -> Transform | Function | None:
|
|
105
|
+
"""*transform* as a transform: a record as it is, a function wrapped in ``Function``, None as None."""
|
|
106
|
+
if transform is None or isinstance(transform, (Transform, Function)):
|
|
107
|
+
return transform
|
|
108
|
+
if callable(transform):
|
|
109
|
+
return Function(transform)
|
|
110
|
+
raise TypeError(f"a transform is a record such as Round(0.5) or a function of the key values, not {transform!r}")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def factorize(values: Any, transform: Any = None, *, dropna: bool = True) -> tuple[np.ndarray, list]:
|
|
114
|
+
"""Group codes for *values* after *transform*, and the group labels in sorted order.
|
|
115
|
+
|
|
116
|
+
Without a transform, positions are grouped by exactly equal values. Every
|
|
117
|
+
missing value (NaN of any sign or payload, NaT) is one missing key:
|
|
118
|
+
``dropna=True`` gives those positions the code -1 and no group;
|
|
119
|
+
``dropna=False`` makes them the last group, labelled NaN (or NaT; text
|
|
120
|
+
for transforms whose labels are text). A value in no group of the
|
|
121
|
+
transform, such as one outside every bin, is an error: values are never
|
|
122
|
+
dropped silently.
|
|
123
|
+
"""
|
|
124
|
+
values = np.asarray(values)
|
|
125
|
+
if values.ndim != 1:
|
|
126
|
+
raise ShapeError(f"factorize needs one key per position, a 1-D array, not {values.ndim} axes")
|
|
127
|
+
transform = as_transform(transform)
|
|
128
|
+
if isinstance(transform, Function):
|
|
129
|
+
keys = np.asarray(transform.function(values))
|
|
130
|
+
if keys.shape != values.shape:
|
|
131
|
+
raise GroupError(f"{transform!r} must give one key per value: {values.shape[0]:,} values gave "
|
|
132
|
+
f"shape {keys.shape}")
|
|
133
|
+
return factorize(keys, dropna=dropna)
|
|
134
|
+
absent = missing(values)
|
|
135
|
+
present = values[~absent]
|
|
136
|
+
if transform is None:
|
|
137
|
+
found, codes_present = np.unique(present, return_inverse=True)
|
|
138
|
+
labels = _label_list(found)
|
|
139
|
+
missing_label = np.array("NaT", dtype=values.dtype)[()] if values.dtype.kind in "mM" else math.nan
|
|
140
|
+
else:
|
|
141
|
+
codes_present, labels = transform.groups(present)
|
|
142
|
+
outside = codes_present < 0
|
|
143
|
+
if outside.any():
|
|
144
|
+
raise GroupError(transform.outside(int(outside.sum())))
|
|
145
|
+
missing_label = transform.missing_label(values.dtype)
|
|
146
|
+
codes = np.full(values.shape, -1, dtype=np.intp)
|
|
147
|
+
codes[~absent] = codes_present.reshape(-1)
|
|
148
|
+
if not dropna and absent.any():
|
|
149
|
+
codes[absent] = len(labels)
|
|
150
|
+
labels = [*labels, missing_label]
|
|
151
|
+
return codes, labels
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# ── Bins ────────────────────────────────────────────────────────────────────
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
class Bins(Transform):
|
|
158
|
+
"""Left-closed, right-open ranges between *edges*: ``[edges[i], edges[i + 1])``.
|
|
159
|
+
|
|
160
|
+
Every bin is a group, empty or not, labelled ``"[low, high)"``, so a
|
|
161
|
+
result shows where nothing fell. A value on an edge is in the bin that
|
|
162
|
+
starts there, so values either side of an edge fall into different
|
|
163
|
+
bins, by design; move the edges to move the split. A value outside every
|
|
164
|
+
bin is in no group, which ``factorize`` refuses.
|
|
165
|
+
"""
|
|
166
|
+
|
|
167
|
+
def __init__(self, edges: Any):
|
|
168
|
+
edges = np.array(edges) # a copy, so the caller's array stays writeable
|
|
169
|
+
_refuse_zones(edges)
|
|
170
|
+
if edges.ndim != 1 or len(edges) < 2 or not (edges[1:] > edges[:-1]).all():
|
|
171
|
+
raise GroupError("needs at least two increasing edges")
|
|
172
|
+
edges.setflags(write=False)
|
|
173
|
+
self.edges = edges
|
|
174
|
+
|
|
175
|
+
def groups(self, values: np.ndarray) -> tuple[np.ndarray, list]:
|
|
176
|
+
_refuse_zones(values)
|
|
177
|
+
codes = np.searchsorted(self.edges, values, side="right") - 1
|
|
178
|
+
codes[codes >= len(self.edges) - 1] = -1
|
|
179
|
+
return codes, [f"[{format_edge(a)}, {format_edge(b)})" for a, b in pairwise(self.edges)]
|
|
180
|
+
|
|
181
|
+
def outside(self, count: int, name: str | None = None) -> str:
|
|
182
|
+
low, high = format_edge(self.edges[0]), format_edge(self.edges[-1])
|
|
183
|
+
return (f"{count:,} positions{_of(name)} fall outside every bin [{low}, {high}); they are never "
|
|
184
|
+
"dropped silently.\n Widen the edges, or filter those positions out first.")
|
|
185
|
+
|
|
186
|
+
def _settings(self) -> tuple:
|
|
187
|
+
kind = self.edges.dtype.kind
|
|
188
|
+
if kind == "M":
|
|
189
|
+
return ("time", tuple(_time_key(edge) for edge in self.edges))
|
|
190
|
+
if kind == "m":
|
|
191
|
+
return ("duration", tuple(_time_key(edge) for edge in self.edges))
|
|
192
|
+
# Whole-number float edges group as integer edges do, but may print differently
|
|
193
|
+
# (1e+20 against 100000000000000000000), so they are other settings.
|
|
194
|
+
return ({"i": "int", "u": "int", "f": "float"}.get(kind, kind), tuple(self.edges.tolist()))
|
|
195
|
+
|
|
196
|
+
def __repr__(self) -> str:
|
|
197
|
+
if self.edges.dtype.kind in "mM":
|
|
198
|
+
texts = [f'"{format_edge(edge)}"' for edge in self.edges]
|
|
199
|
+
else:
|
|
200
|
+
texts = [_number_text(edge) if isinstance(edge, float) else repr(edge) for edge in self.edges.tolist()]
|
|
201
|
+
return f"Bins(edges=[{', '.join(texts)}])"
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
# ── Duration ────────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
class Duration(Transform):
|
|
208
|
+
"""Windows of time of a fixed *width*: ``[origin + k * width, origin + (k + 1) * width)``.
|
|
209
|
+
|
|
210
|
+
*width* is text such as ``"30 days"`` or ``"1h30min"`` (``parse_duration``)
|
|
211
|
+
or a timedelta; months and years have no fixed length and are not
|
|
212
|
+
widths. *origin* places the windows; by default it is the first time
|
|
213
|
+
present. Every window from the first holding a time to the last is a
|
|
214
|
+
group, empty or not, as with ``Bins``. Times either side of a window
|
|
215
|
+
edge fall into different windows, by design; move the origin to move
|
|
216
|
+
the edges. Times with a time zone are refused, as everywhere in
|
|
217
|
+
labelarray: convert them to times without a zone first.
|
|
218
|
+
"""
|
|
219
|
+
|
|
220
|
+
def __init__(self, width: Any, origin: Any = None):
|
|
221
|
+
self.width = _width(width)
|
|
222
|
+
self.origin = None if origin is None else _instant(origin)
|
|
223
|
+
|
|
224
|
+
def groups(self, values: np.ndarray) -> tuple[np.ndarray, list]:
|
|
225
|
+
_refuse_zones(values)
|
|
226
|
+
if values.dtype.kind != "M":
|
|
227
|
+
raise GroupError("a duration needs time values")
|
|
228
|
+
if self.origin is None and values.size == 0:
|
|
229
|
+
raise GroupError("there are no times to bin")
|
|
230
|
+
if values.size == 0:
|
|
231
|
+
return np.zeros(0, dtype=np.intp), []
|
|
232
|
+
start = values.min() if self.origin is None else self.origin
|
|
233
|
+
# Integer arithmetic on the time counts: a window is exact to the nanosecond.
|
|
234
|
+
steps = (values - start) // self.width
|
|
235
|
+
first, last = int(steps.min()), int(steps.max())
|
|
236
|
+
edges = start + np.arange(first, last + 2) * self.width
|
|
237
|
+
labels = [f"[{format_edge(a)}, {format_edge(b)})" for a, b in pairwise(edges)]
|
|
238
|
+
return (steps - first).astype(np.intp), labels
|
|
239
|
+
|
|
240
|
+
def _settings(self) -> tuple:
|
|
241
|
+
return (int(self.width.astype(np.int64)), None if self.origin is None else _time_key(self.origin))
|
|
242
|
+
|
|
243
|
+
def __repr__(self) -> str:
|
|
244
|
+
origin = "None" if self.origin is None else f'"{format_edge(self.origin)}"'
|
|
245
|
+
return f'Duration(width="{duration_text(self.width)}", origin={origin})'
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
# Duration units a duration's text may use, in nanoseconds. Months and years are not
|
|
249
|
+
# fixed lengths, and "m" could be either minutes or months, so none of them are units.
|
|
250
|
+
_WEEK, _DAY, _HOUR, _MINUTE, _SECOND = 7 * 86_400 * 10**9, 86_400 * 10**9, 3_600 * 10**9, 60 * 10**9, 10**9
|
|
251
|
+
_UNITS = {
|
|
252
|
+
**dict.fromkeys(("w", "week", "weeks"), _WEEK),
|
|
253
|
+
**dict.fromkeys(("d", "day", "days"), _DAY),
|
|
254
|
+
**dict.fromkeys(("h", "hr", "hrs", "hour", "hours"), _HOUR),
|
|
255
|
+
**dict.fromkeys(("min", "mins", "minute", "minutes"), _MINUTE),
|
|
256
|
+
**dict.fromkeys(("s", "sec", "secs", "second", "seconds"), _SECOND),
|
|
257
|
+
**dict.fromkeys(("ms", "millisecond", "milliseconds"), 10**6),
|
|
258
|
+
**dict.fromkeys(("us", "microsecond", "microseconds"), 10**3),
|
|
259
|
+
**dict.fromkeys(("ns", "nanosecond", "nanoseconds"), 1),
|
|
260
|
+
}
|
|
261
|
+
_NUMBER_UNIT = r"(\d+(?:\.\d+)?)\s*([A-Za-z]+)"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def parse_duration(text: str) -> np.timedelta64:
|
|
265
|
+
"""A duration such as "30 days", "2D", or "1h30min": numbers with units, added exactly.
|
|
266
|
+
|
|
267
|
+
labelarray reads durations itself, so a duration means the same whether
|
|
268
|
+
or not pandas is installed.
|
|
269
|
+
"""
|
|
270
|
+
parts = re.findall(_NUMBER_UNIT, text)
|
|
271
|
+
rest = re.sub(_NUMBER_UNIT, "", text).strip()
|
|
272
|
+
units = [_UNITS.get(unit.lower()) for _, unit in parts]
|
|
273
|
+
total = sum((Fraction(number) * unit for (number, _), unit in zip(parts, units) if unit), Fraction(0))
|
|
274
|
+
if not parts or rest or not all(units) or total.denominator != 1 or total <= 0:
|
|
275
|
+
raise GroupError(
|
|
276
|
+
"cannot read the duration; write numbers with units "
|
|
277
|
+
'(weeks, days, hours, minutes, seconds, ms, us, ns), e.g. "30 days" or "1h30min", '
|
|
278
|
+
"a whole number of nanoseconds; months and years have no fixed length"
|
|
279
|
+
)
|
|
280
|
+
return np.timedelta64(int(total), "ns")
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
_TEXT_UNITS = ((_DAY, "day", "days"), (_HOUR, "hour", "hours"), (_MINUTE, "minute", "minutes"),
|
|
284
|
+
(_SECOND, "second", "seconds"), (10**6, "ms", "ms"), (10**3, "us", "us"), (1, "ns", "ns"))
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def duration_text(width: np.timedelta64) -> str:
|
|
288
|
+
"""*width* as text ``parse_duration`` reads back: "2 days", "1 hour 30 minutes", "250 ms"."""
|
|
289
|
+
left = int(width.astype("timedelta64[ns]").astype(np.int64))
|
|
290
|
+
parts = []
|
|
291
|
+
for size, one, many in _TEXT_UNITS:
|
|
292
|
+
count, left = divmod(left, size)
|
|
293
|
+
if count:
|
|
294
|
+
parts.append(f"{count} {one if count == 1 else many}")
|
|
295
|
+
return " ".join(parts)
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _width(width: Any) -> np.timedelta64:
|
|
299
|
+
if isinstance(width, str):
|
|
300
|
+
value = parse_duration(width)
|
|
301
|
+
else:
|
|
302
|
+
if hasattr(width, "to_timedelta64"): # pandas' Timedelta, without importing pandas
|
|
303
|
+
width = width.to_timedelta64()
|
|
304
|
+
if isinstance(width, datetime.timedelta):
|
|
305
|
+
width = np.timedelta64(width)
|
|
306
|
+
if not isinstance(width, np.timedelta64):
|
|
307
|
+
raise GroupError(f'a duration is text such as "30 days" or a timedelta, not {width!r}')
|
|
308
|
+
if np.isnat(width) or np.datetime_data(width.dtype)[0] in ("Y", "M"):
|
|
309
|
+
raise GroupError(f"the duration {width} has no fixed length")
|
|
310
|
+
value = width.astype("timedelta64[ns]")
|
|
311
|
+
if value.astype(width.dtype) != width:
|
|
312
|
+
raise GroupError(f"the duration {width} is not a whole number of nanoseconds")
|
|
313
|
+
if value <= np.timedelta64(0, "ns"):
|
|
314
|
+
raise GroupError(f"a duration must be longer than zero, not {width!r}")
|
|
315
|
+
return value
|
|
316
|
+
|
|
317
|
+
|
|
318
|
+
def _instant(origin: Any) -> np.datetime64:
|
|
319
|
+
"""An origin for windows of time, as a NumPy time; a time with a zone is refused."""
|
|
320
|
+
if getattr(origin, "tzinfo", None) is not None:
|
|
321
|
+
raise GroupError(ZONES)
|
|
322
|
+
if hasattr(origin, "to_datetime64"): # pandas' Timestamp, without importing pandas
|
|
323
|
+
origin = origin.to_datetime64()
|
|
324
|
+
try:
|
|
325
|
+
with warnings.catch_warnings():
|
|
326
|
+
# NumPy reads "2026-01-01T00:00+02:00" as UTC with a warning; a zone is refused instead.
|
|
327
|
+
warnings.simplefilter("error")
|
|
328
|
+
value = origin if isinstance(origin, np.datetime64) else np.datetime64(origin)
|
|
329
|
+
except UserWarning:
|
|
330
|
+
raise GroupError(ZONES) from None
|
|
331
|
+
except (TypeError, ValueError):
|
|
332
|
+
raise GroupError(f"an origin is a time, not {origin!r}") from None
|
|
333
|
+
if np.isnat(value):
|
|
334
|
+
raise GroupError("an origin is a time, not NaT")
|
|
335
|
+
return value
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
# The clause refusing times with a time zone, also for callers whose arrays carry the zone in their type.
|
|
339
|
+
ZONES = "needs times without a time zone; convert them to one zone and drop it (to UTC, say) first"
|
|
340
|
+
|
|
341
|
+
|
|
342
|
+
def _refuse_zones(values: np.ndarray) -> None:
|
|
343
|
+
"""Times with a time zone, which reach here only as objects, are refused."""
|
|
344
|
+
if values.dtype.kind == "O" and any(getattr(value, "tzinfo", None) is not None for value in values.flat):
|
|
345
|
+
raise GroupError(ZONES)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
# ── Round and GridCells ─────────────────────────────────────────────────────
|
|
349
|
+
|
|
350
|
+
|
|
351
|
+
class _Steps(Transform):
|
|
352
|
+
"""Numbers placed on a regular grid ``origin + k * step``, held exactly.
|
|
353
|
+
|
|
354
|
+
The step and origin are read as the decimals they print as (``0.1`` is
|
|
355
|
+
one tenth), and each edge is the float nearest the exact edge, so a
|
|
356
|
+
value is in a bucket exactly when it lies between the bucket's edges as
|
|
357
|
+
printed. Integer values with whole-number settings are placed in
|
|
358
|
+
integer arithmetic and labelled with integers. Infinite values are in
|
|
359
|
+
no bucket.
|
|
360
|
+
"""
|
|
361
|
+
|
|
362
|
+
_step_name = "step"
|
|
363
|
+
_bucket = "step"
|
|
364
|
+
|
|
365
|
+
def __init__(self, step: Any, origin: Any = 0):
|
|
366
|
+
self._step = _decimal(step, self._step_name, positive=True)
|
|
367
|
+
self._origin = _decimal(origin, "origin")
|
|
368
|
+
|
|
369
|
+
@property
|
|
370
|
+
def origin(self) -> Fraction:
|
|
371
|
+
return self._origin
|
|
372
|
+
|
|
373
|
+
def _placed(self, values: np.ndarray, start: Fraction) -> tuple[np.ndarray, np.ndarray, np.ndarray | None]:
|
|
374
|
+
"""Each finite value's bucket k, with ``start + k * step <= value < start + (k + 1) * step``.
|
|
375
|
+
|
|
376
|
+
Also which values are finite, and the values as float64 (None when
|
|
377
|
+
they were placed in integer arithmetic).
|
|
378
|
+
"""
|
|
379
|
+
values = _numbers(values)
|
|
380
|
+
line = _Line(start, self._step)
|
|
381
|
+
if values.dtype.kind in "iu":
|
|
382
|
+
return line.floor_integers(values), np.ones(values.shape, dtype=bool), None
|
|
383
|
+
floats = values.astype(np.float64) # exact for float16 and float32
|
|
384
|
+
finite = np.isfinite(floats)
|
|
385
|
+
return line.floor(floats[finite]), finite, floats
|
|
386
|
+
|
|
387
|
+
def _whole(self, values: np.ndarray) -> bool:
|
|
388
|
+
return values.dtype.kind in "iu" and self._step.denominator == 1 and self._origin.denominator == 1
|
|
389
|
+
|
|
390
|
+
def groups(self, values: np.ndarray) -> tuple[np.ndarray, list]:
|
|
391
|
+
start = self._start()
|
|
392
|
+
steps, finite, _ = self._placed(values, start)
|
|
393
|
+
found, codes = _occupied(steps)
|
|
394
|
+
out = np.full(len(finite), -1, dtype=np.intp)
|
|
395
|
+
out[finite] = codes
|
|
396
|
+
return out, self._labels(found, values)
|
|
397
|
+
|
|
398
|
+
def outside(self, count: int, name: str | None = None) -> str:
|
|
399
|
+
return (f"{count:,} positions{_of(name)} are infinite, in no {self._bucket}; they are never dropped "
|
|
400
|
+
"silently.\n Filter those positions out first.")
|
|
401
|
+
|
|
402
|
+
def _settings(self) -> tuple:
|
|
403
|
+
return (self._step, self._origin)
|
|
404
|
+
|
|
405
|
+
def __repr__(self) -> str:
|
|
406
|
+
return (f"{type(self).__name__}({self._step_name}={_fraction_text(self._step)}, "
|
|
407
|
+
f"origin={_fraction_text(self._origin)})")
|
|
408
|
+
|
|
409
|
+
|
|
410
|
+
class Round(_Steps):
|
|
411
|
+
"""Numbers rounded to the nearest ``origin + k * step``, each group labelled by that number.
|
|
412
|
+
|
|
413
|
+
Every bucket is ``[c - step / 2, c + step / 2)`` around its rounded value
|
|
414
|
+
``c``, so halves round up, toward positive infinity, not to even as
|
|
415
|
+
``np.round`` does: one rule for every bucket. Values either side of a
|
|
416
|
+
bucket edge fall into different groups however close they are, by
|
|
417
|
+
design; move the origin to move the edges. Only rounded values that
|
|
418
|
+
occur are groups. Labels are floats, or integers for integer values
|
|
419
|
+
with a whole-number step and origin.
|
|
420
|
+
"""
|
|
421
|
+
|
|
422
|
+
text_labels = False
|
|
423
|
+
|
|
424
|
+
@property
|
|
425
|
+
def step(self) -> Fraction:
|
|
426
|
+
return self._step
|
|
427
|
+
|
|
428
|
+
def _start(self) -> Fraction:
|
|
429
|
+
return self._origin - self._step / 2
|
|
430
|
+
|
|
431
|
+
def _labels(self, found: np.ndarray, values: np.ndarray) -> list:
|
|
432
|
+
centres = _Line(self._origin, self._step)
|
|
433
|
+
if self._whole(values):
|
|
434
|
+
return [int(value) for value in centres.exact(found)]
|
|
435
|
+
return centres.at(found).tolist()
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
class GridCells(_Steps):
|
|
439
|
+
"""Cells ``[origin + k * size, origin + (k + 1) * size)`` of a regular grid, labelled ``"[low, high)"``.
|
|
440
|
+
|
|
441
|
+
One cell per value of a coordinate: used per axis, or combined across
|
|
442
|
+
x, y, and z, the cells bin points into voxels. Only cells holding a
|
|
443
|
+
value are groups, since a grid can be large and sparse. Values either
|
|
444
|
+
side of a cell edge fall into different cells however close they are,
|
|
445
|
+
by design; move the origin to move the edges.
|
|
446
|
+
"""
|
|
447
|
+
|
|
448
|
+
_step_name = "size"
|
|
449
|
+
_bucket = "cell"
|
|
450
|
+
|
|
451
|
+
@property
|
|
452
|
+
def size(self) -> Fraction:
|
|
453
|
+
return self._step
|
|
454
|
+
|
|
455
|
+
def _start(self) -> Fraction:
|
|
456
|
+
return self._origin
|
|
457
|
+
|
|
458
|
+
def _labels(self, found: np.ndarray, values: np.ndarray) -> list:
|
|
459
|
+
edges = _Line(self._origin, self._step)
|
|
460
|
+
if self._whole(values):
|
|
461
|
+
lows = [str(int(value)) for value in edges.exact(found)]
|
|
462
|
+
highs = [str(int(value)) for value in edges.exact(found + 1)]
|
|
463
|
+
else:
|
|
464
|
+
lows = [_number_text(value) for value in edges.at(found).tolist()]
|
|
465
|
+
highs = [_number_text(value) for value in edges.at(found + 1).tolist()]
|
|
466
|
+
return [f"[{low}, {high})" for low, high in zip(lows, highs)]
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
class _Line:
|
|
470
|
+
"""The points ``start + k * step`` for rational *start* and *step*, as exact numbers or nearest floats."""
|
|
471
|
+
|
|
472
|
+
def __init__(self, start: Fraction, step: Fraction):
|
|
473
|
+
self.denominator = math.lcm(start.denominator, step.denominator)
|
|
474
|
+
self.start = int(start * self.denominator)
|
|
475
|
+
self.step = int(step * self.denominator)
|
|
476
|
+
self.float_start, self.float_step = float(start), float(step)
|
|
477
|
+
|
|
478
|
+
def exact(self, ks: np.ndarray) -> list[Fraction]:
|
|
479
|
+
return [Fraction(self.start + int(k) * self.step, self.denominator) for k in ks.tolist()]
|
|
480
|
+
|
|
481
|
+
def at(self, ks: np.ndarray) -> np.ndarray:
|
|
482
|
+
"""The float nearest each point ``start + k * step``."""
|
|
483
|
+
if ks.size == 0:
|
|
484
|
+
return np.zeros(0)
|
|
485
|
+
low, high = int(ks.min()), int(ks.max())
|
|
486
|
+
reach = max(abs(self.start + low * self.step), abs(self.start + high * self.step))
|
|
487
|
+
if reach < 2**53 and self.denominator < 2**53:
|
|
488
|
+
# Both operands are exact floats, and a float division is correctly rounded.
|
|
489
|
+
return (self.start + ks * self.step).astype(np.float64) / float(self.denominator)
|
|
490
|
+
found, inverse = np.unique(ks, return_inverse=True)
|
|
491
|
+
return np.array([float(point) for point in self.exact(found)])[inverse.reshape(-1)]
|
|
492
|
+
|
|
493
|
+
def floor(self, values: np.ndarray) -> np.ndarray:
|
|
494
|
+
"""Each float value's k, with ``at(k) <= value < at(k + 1)``."""
|
|
495
|
+
with np.errstate(all="ignore"):
|
|
496
|
+
guess = np.floor((values - self.float_start) / self.float_step)
|
|
497
|
+
if guess.size and np.abs(guess).max() >= 2**50:
|
|
498
|
+
raise GroupError(f"values up to {np.abs(values).max():g} are too far from the origin to place in steps "
|
|
499
|
+
f"of {self.float_step:g}; choose a nearer origin or a larger step")
|
|
500
|
+
ks = guess.astype(np.int64)
|
|
501
|
+
# The guess rounds twice, so it can be one off near an edge; the edges decide.
|
|
502
|
+
while True:
|
|
503
|
+
down = values < self.at(ks)
|
|
504
|
+
up = values >= self.at(ks + 1)
|
|
505
|
+
if not (down.any() or up.any()):
|
|
506
|
+
return ks
|
|
507
|
+
ks = ks - down + up
|
|
508
|
+
|
|
509
|
+
def floor_integers(self, values: np.ndarray) -> np.ndarray:
|
|
510
|
+
"""Each integer value's k, in integer arithmetic: ``(value * d - start) // step``."""
|
|
511
|
+
reach = (max(abs(int(values.min())), abs(int(values.max()))) * self.denominator + abs(self.start)
|
|
512
|
+
if values.size else 0)
|
|
513
|
+
if reach < 2**63:
|
|
514
|
+
return (values.astype(np.int64) * self.denominator - self.start) // self.step
|
|
515
|
+
return np.array([(int(v) * self.denominator - self.start) // self.step for v in values.tolist()], dtype=object)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _occupied(steps: np.ndarray) -> tuple[np.ndarray, np.ndarray]:
|
|
519
|
+
"""The buckets that hold values, in order, and each value's code: ``np.unique``'s answer.
|
|
520
|
+
|
|
521
|
+
Bucket numbers are integers in a range rarely much wider than the
|
|
522
|
+
values are many, so counting them finds the occupied ones without a sort.
|
|
523
|
+
"""
|
|
524
|
+
if steps.dtype == np.int64 and steps.size:
|
|
525
|
+
low, high = int(steps.min()), int(steps.max())
|
|
526
|
+
if high - low < 4 * steps.size + 1_024:
|
|
527
|
+
offsets = steps - low
|
|
528
|
+
occupied = np.bincount(offsets, minlength=high - low + 1) > 0
|
|
529
|
+
ranks = np.cumsum(occupied) - 1
|
|
530
|
+
return np.flatnonzero(occupied) + low, ranks[offsets].astype(np.intp)
|
|
531
|
+
found, codes = np.unique(steps, return_inverse=True)
|
|
532
|
+
return found, codes.reshape(-1)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _numbers(values: np.ndarray) -> np.ndarray:
|
|
536
|
+
_refuse_zones(values)
|
|
537
|
+
kind = values.dtype.kind
|
|
538
|
+
if kind in "iuf":
|
|
539
|
+
return values
|
|
540
|
+
what = {"b": "booleans", "c": "complex numbers", "M": "times; group times with a Duration",
|
|
541
|
+
"m": "durations", "U": "text", "S": "bytes"}.get(kind, f"{values.dtype} values")
|
|
542
|
+
raise GroupError(f"needs numbers, not {what}")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _decimal(value: Any, what: str, positive: bool = False) -> Fraction:
|
|
546
|
+
"""A setting as the exact number it prints as: the float 0.1 is one tenth."""
|
|
547
|
+
if isinstance(value, (bool, np.bool_)) or not isinstance(value, (int, float, Fraction, Decimal, np.number)):
|
|
548
|
+
raise GroupError(f"the {what} must be a number, not {value!r}")
|
|
549
|
+
if isinstance(value, (complex, np.complexfloating)):
|
|
550
|
+
raise GroupError(f"the {what} must be a real number, not {value!r}")
|
|
551
|
+
if isinstance(value, (float, np.floating, Decimal)) and not math.isfinite(value):
|
|
552
|
+
raise GroupError(f"the {what} must be a finite number, not {value!r}")
|
|
553
|
+
# str() gives the shortest decimal that reads back as the same float (float32's too).
|
|
554
|
+
number = Fraction(str(value)) if isinstance(value, (float, np.floating)) else Fraction(value)
|
|
555
|
+
if positive and number <= 0:
|
|
556
|
+
raise GroupError(f"the {what} must be a positive number, not {value!r}")
|
|
557
|
+
return number
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
# ── Labels ──────────────────────────────────────────────────────────────────
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def format_edge(value: Any) -> str:
|
|
564
|
+
"""An edge as a label shows it: times as dates where whole days, numbers in ``%g``."""
|
|
565
|
+
if isinstance(value, np.datetime64):
|
|
566
|
+
nanoseconds = value.astype("datetime64[ns]")
|
|
567
|
+
if nanoseconds == nanoseconds.astype("datetime64[D]"):
|
|
568
|
+
return str(np.datetime_as_string(nanoseconds, unit="D"))
|
|
569
|
+
for unit in ("s", "us"): # as pandas prints: whole seconds, microseconds, else nanoseconds
|
|
570
|
+
if nanoseconds == nanoseconds.astype(f"datetime64[{unit}]"):
|
|
571
|
+
return str(np.datetime_as_string(nanoseconds, unit=unit))
|
|
572
|
+
return str(np.datetime_as_string(nanoseconds, unit="ns"))
|
|
573
|
+
if isinstance(value, np.generic) and value.dtype.kind != "m":
|
|
574
|
+
value = value.item()
|
|
575
|
+
return f"{value:g}" if isinstance(value, float) else str(value)
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
def _number_text(value: float) -> str:
|
|
579
|
+
"""The shortest text that reads back as *value*, without a trailing ".0"."""
|
|
580
|
+
return repr(float(value)).removesuffix(".0")
|
|
581
|
+
|
|
582
|
+
|
|
583
|
+
def _fraction_text(value: Fraction) -> str:
|
|
584
|
+
if value.denominator == 1:
|
|
585
|
+
return str(value.numerator)
|
|
586
|
+
as_float = float(value)
|
|
587
|
+
return _number_text(as_float) if Fraction(str(as_float)) == value else f"{value.numerator}/{value.denominator}"
|
|
588
|
+
|
|
589
|
+
|
|
590
|
+
def _label_list(labels: np.ndarray) -> list:
|
|
591
|
+
"""Labels as Python values, times and durations as NumPy scalars."""
|
|
592
|
+
if labels.dtype.kind in "mM":
|
|
593
|
+
return list(labels)
|
|
594
|
+
return labels.tolist()
|
|
595
|
+
|
|
596
|
+
|
|
597
|
+
def _of(name: str | None) -> str:
|
|
598
|
+
return "" if name is None else f" of {name}"
|
|
599
|
+
|
|
600
|
+
|
|
601
|
+
# Seconds per NumPy time unit, so equal times in different units are equal settings.
|
|
602
|
+
_SECONDS = {"W": 604_800, "D": 86_400, "h": 3_600, "m": 60, "s": 1, "ms": Fraction(1, 10**3),
|
|
603
|
+
"us": Fraction(1, 10**6), "ns": Fraction(1, 10**9), "ps": Fraction(1, 10**12),
|
|
604
|
+
"fs": Fraction(1, 10**15), "as": Fraction(1, 10**18)}
|
|
605
|
+
|
|
606
|
+
|
|
607
|
+
def _time_key(value: np.generic) -> Any:
|
|
608
|
+
unit, count = np.datetime_data(value.dtype)
|
|
609
|
+
ticks = int(value.astype(np.int64)) * count
|
|
610
|
+
return Fraction(ticks) * _SECONDS[unit] if unit in _SECONDS else (unit, ticks)
|