scales-python 1.4.0.9000__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.
scales/_utils.py ADDED
@@ -0,0 +1,579 @@
1
+ """
2
+ Utility functions for the scales package.
3
+
4
+ Python port of utility functions from the R scales package
5
+ (https://github.com/r-lib/scales). Corresponds primarily to:
6
+ - R/utils.R
7
+ - R/range.R
8
+ - R/full-seq.R
9
+ - R/round.R
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import sys
15
+ from datetime import timedelta
16
+ from typing import Any, Callable, Optional, Sequence, Union
17
+
18
+ import numpy as np
19
+ from numpy.typing import ArrayLike
20
+
21
+ __all__ = [
22
+ "zero_range",
23
+ "expand_range",
24
+ "rescale_common",
25
+ "recycle_common",
26
+ "fullseq",
27
+ "round_any",
28
+ "offset_by",
29
+ "precision",
30
+ "demo_continuous",
31
+ "demo_log10",
32
+ "demo_discrete",
33
+ "demo_datetime",
34
+ "demo_time",
35
+ "demo_timespan",
36
+ ]
37
+
38
+
39
+ def zero_range(
40
+ x: ArrayLike, tol: float = 1000 * sys.float_info.epsilon
41
+ ) -> bool:
42
+ """
43
+ Check if a range has (effectively) zero extent.
44
+
45
+ Parameters
46
+ ----------
47
+ x : array-like of length 2
48
+ A ``[min, max]`` pair describing a range.
49
+ tol : float, optional
50
+ Tolerance for comparison, by default ``1000 * sys.float_info.epsilon``.
51
+ The range is considered zero when
52
+ ``abs(max - min) < tol * abs(mean(min, max))``.
53
+
54
+ Returns
55
+ -------
56
+ bool
57
+ ``True`` if the range is effectively zero, ``False`` otherwise.
58
+
59
+ Notes
60
+ -----
61
+ * If either element is ``None`` / ``np.nan``, returns ``True``
62
+ (consistent with R's ``NA`` handling).
63
+ * If both elements are infinite with the same sign, returns ``False``.
64
+ * If elements are infinite with different signs, returns ``False``.
65
+ """
66
+ x = np.asarray(x, dtype=float)
67
+ if x.shape != (2,):
68
+ raise ValueError("x must have exactly 2 elements")
69
+
70
+ # R returns NA for NaN inputs (which causes an error in `if`).
71
+ # In Python, returning True would incorrectly treat NaN ranges as
72
+ # zero-width. Return False so downstream code takes the safe path.
73
+ if np.any(np.isnan(x)):
74
+ return False
75
+
76
+ # Both the same (including both +Inf or both -Inf)
77
+ if x[0] == x[1]:
78
+ return True
79
+
80
+ # Mixed infinities → definitely not zero range
81
+ if np.any(np.isinf(x)):
82
+ return False
83
+
84
+ diff = np.abs(x[1] - x[0])
85
+ mean = np.abs(x[0] + x[1]) / 2.0
86
+
87
+ if mean == 0.0:
88
+ # Avoid 0/0; compare diff to tol directly
89
+ return diff < tol
90
+
91
+ return (diff / mean) < tol
92
+
93
+
94
+ def expand_range(
95
+ range: ArrayLike,
96
+ mul: float = 0,
97
+ add: float = 0,
98
+ zero_width: float = 1,
99
+ ) -> tuple[float, float]:
100
+ """
101
+ Expand a numeric range by multiplicative and additive amounts.
102
+
103
+ Parameters
104
+ ----------
105
+ range : array-like of length 2
106
+ ``[min, max]`` of the data range.
107
+ mul : float, optional
108
+ Multiplicative expansion factor (default 0). The range is expanded
109
+ outward by ``mul * (max - min)`` on each side.
110
+ add : float, optional
111
+ Additive expansion amount (default 0). Added to each side of the
112
+ range after multiplicative expansion.
113
+ zero_width : float, optional
114
+ If the range has zero width (see :func:`zero_range`), expand by
115
+ this amount instead (default 1). Half is subtracted from the
116
+ minimum and half is added to the maximum.
117
+
118
+ Returns
119
+ -------
120
+ tuple of float
121
+ ``(new_min, new_max)`` after expansion.
122
+ """
123
+ range = np.asarray(range, dtype=float)
124
+ if range.shape != (2,):
125
+ raise ValueError("range must have exactly 2 elements")
126
+
127
+ # Matches R exactly:
128
+ # width <- if (zero_range(range)) zero_width else diff(range)
129
+ # range + c(-1, 1) * (width * mul + add)
130
+ # So with the defaults (mul=0, add=0) a zero-range input returns
131
+ # unchanged, and the `zero_width` only participates via `mul`.
132
+ width = zero_width if zero_range(range) else range[1] - range[0]
133
+ delta = width * mul + add
134
+ return (float(range[0] - delta), float(range[1] + delta))
135
+
136
+
137
+ def rescale_common(
138
+ x: ArrayLike,
139
+ to: tuple[float, float],
140
+ from_range: tuple[float, float],
141
+ ) -> np.ndarray:
142
+ """
143
+ Linearly rescale *x* from ``from_range`` into ``to``.
144
+
145
+ Parameters
146
+ ----------
147
+ x : array-like
148
+ Numeric values to rescale.
149
+ to : tuple of float
150
+ ``(new_min, new_max)`` target range.
151
+ from_range : tuple of float
152
+ ``(old_min, old_max)`` source range.
153
+
154
+ Returns
155
+ -------
156
+ numpy.ndarray
157
+ Rescaled values.
158
+ """
159
+ x = np.asarray(x, dtype=float)
160
+ from_range = np.asarray(from_range, dtype=float)
161
+ to = np.asarray(to, dtype=float)
162
+
163
+ if zero_range(from_range):
164
+ return np.full_like(x, (to[0] + to[1]) / 2.0)
165
+
166
+ return (x - from_range[0]) / (from_range[1] - from_range[0]) * (
167
+ to[1] - to[0]
168
+ ) + to[0]
169
+
170
+
171
+ def recycle_common(
172
+ *args: ArrayLike, size: Optional[int] = None
173
+ ) -> list[np.ndarray]:
174
+ """
175
+ Recycle arrays to a common length.
176
+
177
+ Each argument must be either length 1 (scalar) or length ``size``.
178
+ Scalars are broadcast to length ``size``. If *size* is ``None`` it is
179
+ inferred as the length of the longest non-scalar argument.
180
+
181
+ Parameters
182
+ ----------
183
+ *args : array-like
184
+ One or more arrays to recycle.
185
+ size : int, optional
186
+ Target length. Inferred from the arguments when ``None``.
187
+
188
+ Returns
189
+ -------
190
+ list of numpy.ndarray
191
+ Recycled arrays, all of length *size*.
192
+
193
+ Raises
194
+ ------
195
+ ValueError
196
+ If arguments have incompatible lengths (not 1 and not *size*).
197
+ """
198
+ arrays = [np.atleast_1d(np.asarray(a)) for a in args]
199
+
200
+ if size is None:
201
+ lengths = [len(a) for a in arrays]
202
+ non_scalar = [l for l in lengths if l != 1]
203
+ if not non_scalar:
204
+ size = 1
205
+ else:
206
+ size = max(non_scalar)
207
+
208
+ result: list[np.ndarray] = []
209
+ for i, a in enumerate(arrays):
210
+ n = len(a)
211
+ if n == size:
212
+ result.append(a)
213
+ elif n == 1:
214
+ result.append(np.repeat(a, size))
215
+ else:
216
+ raise ValueError(
217
+ f"Argument {i} has length {n}, which is not 1 or {size}"
218
+ )
219
+
220
+ return result
221
+
222
+
223
+ def fullseq(
224
+ range: ArrayLike,
225
+ size: float,
226
+ pad: bool = False,
227
+ ) -> np.ndarray:
228
+ """
229
+ Generate a sequence of fixed-size intervals that covers *range*.
230
+
231
+ Parameters
232
+ ----------
233
+ range : array-like of length 2
234
+ ``[min, max]`` of the data range.
235
+ size : float
236
+ Step size for the sequence.
237
+ pad : bool, optional
238
+ If ``True``, extend the sequence by one *size* on each side
239
+ (default ``False``).
240
+
241
+ Returns
242
+ -------
243
+ numpy.ndarray
244
+ A regular numeric sequence from (at most) ``min`` to (at least)
245
+ ``max``, with spacing *size*.
246
+ """
247
+ range = np.asarray(range, dtype=float)
248
+ if range.shape != (2,):
249
+ raise ValueError("range must have exactly 2 elements")
250
+
251
+ if not np.isfinite(size) or size <= 0:
252
+ raise ValueError("size must be a positive finite number")
253
+
254
+ lo = np.floor(range[0] / size) * size
255
+ hi = np.ceil(range[1] / size) * size
256
+
257
+ if pad:
258
+ lo -= size
259
+ hi += size
260
+
261
+ # Use round_any to avoid floating-point fuzz at boundaries
262
+ return np.arange(lo, hi + size / 2, size)
263
+
264
+
265
+ def round_any(
266
+ x: ArrayLike,
267
+ accuracy: float,
268
+ f: Callable = np.round,
269
+ ) -> np.ndarray:
270
+ """
271
+ Round values to the nearest multiple of *accuracy*.
272
+
273
+ Parameters
274
+ ----------
275
+ x : array-like
276
+ Numeric values to round.
277
+ accuracy : float
278
+ Rounding unit; values are rounded to the nearest multiple of
279
+ this number.
280
+ f : callable, optional
281
+ Rounding function, by default :func:`numpy.round`. Other useful
282
+ choices include :func:`numpy.floor` and :func:`numpy.ceil`.
283
+
284
+ Returns
285
+ -------
286
+ numpy.ndarray
287
+ Rounded values.
288
+ """
289
+ x = np.asarray(x, dtype=float)
290
+ return f(x / accuracy) * accuracy
291
+
292
+
293
+ def offset_by(
294
+ x: Union[float, np.datetime64, Any],
295
+ size: Union[float, timedelta, np.timedelta64, Any],
296
+ ) -> Any:
297
+ """
298
+ Offset a value by *size*.
299
+
300
+ For plain numerics, this is simple addition. For datetime-like objects
301
+ the *size* should be an appropriate timedelta.
302
+
303
+ Parameters
304
+ ----------
305
+ x : float or datetime-like
306
+ Starting value.
307
+ size : float, timedelta, or numpy.timedelta64
308
+ Amount to offset by.
309
+
310
+ Returns
311
+ -------
312
+ float or datetime-like
313
+ ``x + size``.
314
+ """
315
+ return x + size
316
+
317
+
318
+ def precision(x: ArrayLike) -> float:
319
+ """
320
+ Detect the precision of a numeric vector.
321
+
322
+ The precision is the smallest power of 10 that captures the spacing
323
+ between unique, finite, non-NaN values.
324
+
325
+ Parameters
326
+ ----------
327
+ x : array-like
328
+ Numeric values.
329
+
330
+ Returns
331
+ -------
332
+ float
333
+ Precision as a power of 10 (e.g. ``0.01`` for data with two
334
+ decimal places of resolution).
335
+
336
+ Notes
337
+ -----
338
+ If *x* has fewer than 2 unique finite values, returns ``1``.
339
+ """
340
+ x = np.asarray(x, dtype=float)
341
+ x = x[np.isfinite(x)]
342
+ x = np.unique(x)
343
+
344
+ if len(x) <= 1:
345
+ return 1.0
346
+
347
+ diffs = np.diff(np.sort(x))
348
+ diffs = diffs[diffs > 0]
349
+
350
+ if len(diffs) == 0:
351
+ return 1.0
352
+
353
+ smallest = np.min(diffs)
354
+
355
+ if smallest == 0:
356
+ return 1.0
357
+
358
+ # Smallest power of 10 <= smallest diff.
359
+ # Round the log10 to avoid floating-point fuzz (e.g. log10(0.1) ≈ -1.0000000000000004).
360
+ log_val = np.log10(smallest)
361
+ rounded = np.round(log_val)
362
+ if np.abs(log_val - rounded) < 1e-6:
363
+ log_val = rounded
364
+ return float(10 ** np.floor(log_val))
365
+
366
+
367
+ # ---------------------------------------------------------------------------
368
+ # Demo functions (R source: utils.R)
369
+ # ---------------------------------------------------------------------------
370
+
371
+
372
+ def demo_continuous(
373
+ x: ArrayLike,
374
+ *,
375
+ labels: object = None,
376
+ breaks: object = None,
377
+ trans: object = None,
378
+ **kwargs: object,
379
+ ) -> None:
380
+ """Show a continuous scale demo using matplotlib.
381
+
382
+ Creates a simple plot that demonstrates how breaks and labels
383
+ render for the supplied data range. The R version delegates to
384
+ ggplot2; this Python version uses matplotlib.
385
+
386
+ Parameters
387
+ ----------
388
+ x : array-like
389
+ Data range (used as x-limits).
390
+ labels : callable or None
391
+ Label formatter (e.g. ``label_comma()``).
392
+ breaks : callable or None
393
+ Break generator (e.g. ``breaks_extended(n=5)``).
394
+ trans : Transform or None
395
+ Transformation object.
396
+ **kwargs
397
+ Ignored (accepted for forward-compatibility).
398
+ """
399
+ import matplotlib.pyplot as plt
400
+
401
+ x = np.asarray(x, dtype=float)
402
+ xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x))
403
+
404
+ fig, ax = plt.subplots(figsize=(6, 1))
405
+
406
+ # Generate breaks
407
+ if breaks is not None:
408
+ ticks = np.asarray(breaks(x))
409
+ else:
410
+ ticks = np.linspace(xmin, xmax, 6)
411
+
412
+ # Generate labels
413
+ if labels is not None:
414
+ tick_labels = labels(ticks)
415
+ else:
416
+ tick_labels = [str(v) for v in ticks]
417
+
418
+ ax.set_xlim(xmin, xmax)
419
+ ax.set_xticks(ticks)
420
+ ax.set_xticklabels(tick_labels)
421
+ ax.set_yticks([])
422
+ ax.set_title("Continuous scale demo")
423
+ plt.tight_layout()
424
+ plt.show()
425
+
426
+
427
+ def demo_log10(
428
+ x: ArrayLike,
429
+ *,
430
+ labels: object = None,
431
+ breaks: object = None,
432
+ **kwargs: object,
433
+ ) -> None:
434
+ """Show a log-10 scale demo using matplotlib.
435
+
436
+ Parameters
437
+ ----------
438
+ x : array-like
439
+ Data range.
440
+ labels : callable or None
441
+ Label formatter.
442
+ breaks : callable or None
443
+ Break generator.
444
+ **kwargs
445
+ Ignored.
446
+ """
447
+ import matplotlib.pyplot as plt
448
+
449
+ x = np.asarray(x, dtype=float)
450
+ xmin, xmax = float(np.nanmin(x)), float(np.nanmax(x))
451
+
452
+ fig, ax = plt.subplots(figsize=(6, 1))
453
+ ax.set_xscale("log")
454
+ ax.set_xlim(max(xmin, 1e-10), xmax)
455
+
456
+ if breaks is not None:
457
+ ticks = np.asarray(breaks(x))
458
+ ax.set_xticks(ticks)
459
+ if labels is not None:
460
+ ax.set_xticklabels(labels(ax.get_xticks()))
461
+
462
+ ax.set_yticks([])
463
+ ax.set_title("Log-10 scale demo")
464
+ plt.tight_layout()
465
+ plt.show()
466
+
467
+
468
+ def demo_discrete(
469
+ x: ArrayLike,
470
+ *,
471
+ labels: object = None,
472
+ **kwargs: object,
473
+ ) -> None:
474
+ """Show a discrete scale demo using matplotlib.
475
+
476
+ Parameters
477
+ ----------
478
+ x : array-like
479
+ Categorical values.
480
+ labels : callable or None
481
+ Label formatter.
482
+ **kwargs
483
+ Ignored.
484
+ """
485
+ import matplotlib.pyplot as plt
486
+
487
+ x = list(x) if not isinstance(x, (list, np.ndarray)) else list(x)
488
+ positions = list(range(len(x)))
489
+
490
+ fig, ax = plt.subplots(figsize=(6, 1))
491
+ ax.set_xlim(-0.5, len(x) - 0.5)
492
+ ax.set_xticks(positions)
493
+
494
+ if labels is not None:
495
+ tick_labels = labels(x)
496
+ else:
497
+ tick_labels = [str(v) for v in x]
498
+
499
+ ax.set_xticklabels(tick_labels)
500
+ ax.set_yticks([])
501
+ ax.set_title("Discrete scale demo")
502
+ plt.tight_layout()
503
+ plt.show()
504
+
505
+
506
+ def demo_datetime(
507
+ x: ArrayLike,
508
+ *,
509
+ labels: object = None,
510
+ breaks: object = None,
511
+ **kwargs: object,
512
+ ) -> None:
513
+ """Show a datetime scale demo using matplotlib.
514
+
515
+ Parameters
516
+ ----------
517
+ x : array-like
518
+ Datetime values.
519
+ labels : callable or None
520
+ Label formatter.
521
+ breaks : callable or None
522
+ Break generator.
523
+ **kwargs
524
+ Ignored.
525
+ """
526
+ import matplotlib.pyplot as plt
527
+
528
+ fig, ax = plt.subplots(figsize=(8, 2), layout="constrained")
529
+ ax.set_xlim(min(x), max(x))
530
+ fig.autofmt_xdate()
531
+ ax.set_yticks([])
532
+ ax.set_title("Datetime scale demo")
533
+ plt.show()
534
+
535
+
536
+ def demo_time(
537
+ x: ArrayLike,
538
+ *,
539
+ labels: object = None,
540
+ breaks: object = None,
541
+ **kwargs: object,
542
+ ) -> None:
543
+ """Show a time scale demo using matplotlib.
544
+
545
+ Parameters
546
+ ----------
547
+ x : array-like
548
+ Time/numeric values representing seconds.
549
+ labels : callable or None
550
+ Label formatter.
551
+ breaks : callable or None
552
+ Break generator.
553
+ **kwargs
554
+ Ignored.
555
+ """
556
+ demo_continuous(x, labels=labels, breaks=breaks, **kwargs)
557
+
558
+
559
+ def demo_timespan(
560
+ x: ArrayLike,
561
+ *,
562
+ labels: object = None,
563
+ breaks: object = None,
564
+ **kwargs: object,
565
+ ) -> None:
566
+ """Show a timespan scale demo using matplotlib.
567
+
568
+ Parameters
569
+ ----------
570
+ x : array-like
571
+ Timespan data (numeric seconds).
572
+ labels : callable or None
573
+ Label formatter.
574
+ breaks : callable or None
575
+ Break generator.
576
+ **kwargs
577
+ Ignored.
578
+ """
579
+ demo_continuous(x, labels=labels, breaks=breaks, **kwargs)