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/breaks_log.py ADDED
@@ -0,0 +1,268 @@
1
+ """
2
+ Break generators for log-scaled axes.
3
+
4
+ Faithful Python port of ``R/breaks-log.R``:
5
+
6
+ * ``breaks_log`` replicates R's algorithm: look for integer powers of
7
+ ``base``; when too few, densify via ``log_sub_breaks`` (Wilkinson-style
8
+ greedy candidate selection), falling back to ``extended_breaks``.
9
+ * ``minor_breaks_log`` honours R's ``detail in {1, 5, 10}`` contract,
10
+ handles negatives by mirroring ticks across zero, and exposes the
11
+ selected detail on the result as a ``.detail`` attribute.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import math
17
+ from typing import Callable, Optional
18
+
19
+ import numpy as np
20
+ from numpy.typing import ArrayLike
21
+
22
+ __all__ = [
23
+ "breaks_log",
24
+ "log_breaks", # R alias: log_breaks <- breaks_log
25
+ "minor_breaks_log",
26
+ ]
27
+
28
+
29
+ # ---------------------------------------------------------------------------
30
+ # breaks_log
31
+ # ---------------------------------------------------------------------------
32
+
33
+ def _log_sub_breaks(
34
+ rng: tuple[float, float], n: int = 5, base: float = 10
35
+ ) -> np.ndarray:
36
+ """Port of R's internal ``log_sub_breaks``.
37
+
38
+ Greedily picks candidate integers in ``(1, base)`` that, added to
39
+ the current ``steps`` set, maximise the minimum log-spacing gap.
40
+ Repeats until there are at least ``n - 2`` relevant breaks or the
41
+ candidate pool is exhausted. Falls back to
42
+ :func:`scales.breaks.breaks_extended` when no admissible set is
43
+ found.
44
+ """
45
+ lo = math.floor(rng[0])
46
+ hi = math.ceil(rng[1])
47
+ if base <= 2:
48
+ return np.array(
49
+ [base ** p for p in range(int(lo), int(hi) + 1)], dtype=float
50
+ )
51
+
52
+ steps: list[float] = [1.0]
53
+
54
+ def _delta(x: float) -> float:
55
+ candidate = sorted({x, *steps, base})
56
+ log_vals = [math.log(v, base) for v in candidate]
57
+ diffs = [log_vals[i + 1] - log_vals[i] for i in range(len(log_vals) - 1)]
58
+ return min(diffs)
59
+
60
+ candidate_pool = [c for c in range(2, int(base))]
61
+
62
+ breaks = np.array([], dtype=float)
63
+ relevant_count = 0
64
+ powers = np.arange(int(lo), int(hi) + 1)
65
+
66
+ while candidate_pool:
67
+ # Pick the candidate that maximises the minimum log spacing.
68
+ deltas = [_delta(c) for c in candidate_pool]
69
+ best_idx = int(np.argmax(deltas))
70
+ steps.append(float(candidate_pool.pop(best_idx)))
71
+
72
+ # Regenerate break set: outer product of base^powers and steps.
73
+ breaks = np.sort(
74
+ np.asarray([b * s for b in base ** powers for s in steps], dtype=float)
75
+ )
76
+ mask = (base ** rng[0] <= breaks) & (breaks <= base ** rng[1])
77
+ relevant_count = int(np.sum(mask))
78
+ if relevant_count >= (n - 2):
79
+ break
80
+
81
+ if relevant_count >= (n - 2):
82
+ # Include one extra break on each side when available, mirroring
83
+ # R's `lower_end` / `upper_end` logic.
84
+ inside = np.where((base ** rng[0] <= breaks) & (breaks <= base ** rng[1]))[0]
85
+ if inside.size == 0:
86
+ return np.array([], dtype=float)
87
+ lower_end = max(int(inside.min()) - 1, 0)
88
+ upper_end = min(int(inside.max()) + 1, len(breaks) - 1)
89
+ return breaks[lower_end:upper_end + 1]
90
+
91
+ # Fallback: Wilkinson extended on the original (exp'd) range.
92
+ from .breaks import breaks_extended
93
+ return breaks_extended(n=n)(np.asarray([base ** rng[0], base ** rng[1]]))
94
+
95
+
96
+ def breaks_log(
97
+ n: int = 5,
98
+ base: float = 10,
99
+ ) -> Callable[[ArrayLike], np.ndarray]:
100
+ """Create a break generator for log-scaled axes.
101
+
102
+ Faithful port of R's ``breaks_log``:
103
+
104
+ 1. Take ``range(x)`` ignoring non-finite. If not finite → empty.
105
+ 2. Compute integer-power candidates ``base^seq(floor(lo), ceil(hi))``.
106
+ 3. If fewer than ``n - 2`` fall inside the data range, shrink the
107
+ step ``by`` from ``floor((max - min) / n) + 1`` down to 1; when
108
+ that's still not enough, delegate to ``log_sub_breaks``.
109
+
110
+ Parameters
111
+ ----------
112
+ n : int, optional
113
+ Target number of breaks (default 5).
114
+ base : float, optional
115
+ Logarithm base (default 10).
116
+
117
+ Returns
118
+ -------
119
+ callable
120
+ A function ``(x) -> numpy.ndarray`` of break positions.
121
+ """
122
+
123
+ def _breaks(x: ArrayLike) -> np.ndarray:
124
+ x_arr = np.asarray(x, dtype=float)
125
+ finite = x_arr[np.isfinite(x_arr) & (x_arr > 0)]
126
+ if finite.size == 0:
127
+ return np.array([], dtype=float)
128
+
129
+ raw_rng = (float(np.min(finite)), float(np.max(finite)))
130
+ rng = (math.log(raw_rng[0], base), math.log(raw_rng[1], base))
131
+ lo = math.floor(rng[0])
132
+ hi = math.ceil(rng[1])
133
+
134
+ if hi == lo:
135
+ return np.array([base ** lo], dtype=float)
136
+
137
+ by = math.floor((hi - lo) / n) + 1
138
+ while by >= 1:
139
+ powers = np.arange(lo, hi + by, by)
140
+ breaks = base ** powers
141
+ mask = (base ** rng[0] <= breaks) & (breaks <= base ** rng[1])
142
+ if int(np.sum(mask)) >= (n - 2):
143
+ return breaks
144
+ by -= 1
145
+
146
+ return _log_sub_breaks(rng, n=n, base=base)
147
+
148
+ return _breaks
149
+
150
+
151
+ log_breaks = breaks_log
152
+
153
+
154
+ # ---------------------------------------------------------------------------
155
+ # minor_breaks_log
156
+ # ---------------------------------------------------------------------------
157
+
158
+ def minor_breaks_log(
159
+ detail: Optional[int] = None,
160
+ smallest: Optional[float] = None,
161
+ ) -> Callable[[np.ndarray, np.ndarray, int], np.ndarray]:
162
+ """Create a minor-break generator for log-10 axes.
163
+
164
+ Faithful port of R's ``minor_breaks_log``:
165
+
166
+ * ``detail`` must be one of ``{1, 5, 10}`` if given.
167
+ - ``10`` → tens ladder only (``10^k``).
168
+ - ``5`` → tens plus mid-decade ``5 * 10^k``.
169
+ - ``1`` → tens plus fives plus ones (``1..9 * 10^k``).
170
+ * When ``detail`` is ``None``, the function auto-selects based on
171
+ the number of decades covered: ``> 15`` → 10, ``> 8`` → 5, else 1.
172
+ * When any data value is ``<= 0`` (so a signed-log scale such as
173
+ ``asinh`` is in use), the ladder is reflected about zero and a
174
+ ``0`` tick is added.
175
+
176
+ Parameters
177
+ ----------
178
+ detail : int, optional
179
+ One of 1, 5, or 10.
180
+ smallest : float, optional
181
+ Smallest absolute value to include when negatives are present.
182
+ Defaults to ``min(1, max(|x|)) * 0.1``.
183
+
184
+ Returns
185
+ -------
186
+ callable
187
+ Function ``(x, ...) -> numpy.ndarray``. The returned array
188
+ carries a ``.detail`` attribute (per-tick 10/5/1) mirroring R.
189
+ """
190
+ if detail is not None and detail not in (1, 5, 10):
191
+ raise ValueError("detail must be one of 1, 5, or 10")
192
+ if smallest is not None:
193
+ if not np.isfinite(smallest) or smallest <= 0 or smallest < 1e-100:
194
+ raise ValueError(
195
+ "smallest must be a finite positive number >= 1e-100"
196
+ )
197
+
198
+ def _minor_breaks(x: np.ndarray, *args, **kwargs) -> np.ndarray:
199
+ x_arr = np.asarray(x, dtype=float)
200
+ has_negatives = bool(np.any(x_arr <= 0))
201
+
202
+ if has_negatives:
203
+ large = float(np.nanmax(np.abs(x_arr)))
204
+ small = smallest if smallest is not None else min(1.0, large) * 0.1
205
+ # Work with (small*10, large) as the effective positive range.
206
+ x_used = np.sort(np.array([small * 10.0, large]))
207
+ else:
208
+ x_used = x_arr[x_arr > 0]
209
+ small = None
210
+
211
+ start = int(math.floor(math.log10(np.min(x_used)))) - 1
212
+ end = int(math.ceil(math.log10(np.max(x_used)))) + 1
213
+
214
+ if detail is None:
215
+ # R: findInterval(abs(end - start), c(8, 15), left.open=TRUE) + 1
216
+ span = abs(end - start)
217
+ if span > 15:
218
+ chosen = 10
219
+ elif span > 8:
220
+ chosen = 5
221
+ else:
222
+ chosen = 1
223
+ else:
224
+ chosen = detail
225
+
226
+ ladder = np.array(
227
+ [10.0 ** p for p in range(start, end + 1)], dtype=float
228
+ )
229
+
230
+ tens = np.array([], dtype=float)
231
+ fives = np.array([], dtype=float)
232
+ ones = np.array([], dtype=float)
233
+ if chosen in (10, 5, 1):
234
+ tens = ladder
235
+ if chosen in (5, 1):
236
+ fives = 5.0 * ladder
237
+ if chosen == 1:
238
+ ones = np.array(
239
+ [m * p for p in ladder for m in range(1, 10)], dtype=float
240
+ )
241
+ ones = np.setdiff1d(ones, np.concatenate([tens, fives]))
242
+
243
+ if has_negatives:
244
+ tens = tens[tens >= small]
245
+ tens = np.concatenate([tens, -tens, np.array([0.0])])
246
+ fives = fives[fives >= small]
247
+ fives = np.concatenate([fives, -fives])
248
+ ones = ones[ones >= small]
249
+ ones = np.concatenate([ones, -ones])
250
+
251
+ ticks = np.concatenate([tens, fives, ones])
252
+ # R attaches detail codes (10/5/1) per tick via attr().
253
+ # numpy arrays don't take arbitrary attributes on the stock
254
+ # dtype, but subclassing works. We attach via np.ndarray view.
255
+ detail_codes = np.concatenate([
256
+ np.full(len(tens), 10, dtype=int),
257
+ np.full(len(fives), 5, dtype=int),
258
+ np.full(len(ones), 1, dtype=int),
259
+ ])
260
+ result = ticks.view(np.ndarray)
261
+ try:
262
+ result = result.copy()
263
+ result.detail = detail_codes # type: ignore[attr-defined]
264
+ except AttributeError:
265
+ pass
266
+ return result
267
+
268
+ return _minor_breaks