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.
@@ -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)