plot-io 0.12.0__py3-none-win_amd64.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.
Files changed (130) hide show
  1. plot_io/__init__.py +73 -0
  2. plot_io/_methods.py +1691 -0
  3. plot_io/_native/__init__.py +199 -0
  4. plot_io/_native/__main__.py +197 -0
  5. plot_io/_native/_api.py +457 -0
  6. plot_io/_native/bin/windows-x86_64.dll +0 -0
  7. plot_io/_native/kernels.py +335 -0
  8. plot_io/_native/manifest.json +49 -0
  9. plot_io/axis.py +1347 -0
  10. plot_io/boxes.py +1156 -0
  11. plot_io/canvas.py +389 -0
  12. plot_io/color/__init__.py +3 -0
  13. plot_io/color/data.py +1646 -0
  14. plot_io/color/maps.py +922 -0
  15. plot_io/color/values.py +295 -0
  16. plot_io/color/variants.py +1246 -0
  17. plot_io/colorbar.py +1426 -0
  18. plot_io/columns.py +614 -0
  19. plot_io/decorations.py +920 -0
  20. plot_io/defaults.py +809 -0
  21. plot_io/density.py +113 -0
  22. plot_io/ext.py +68 -0
  23. plot_io/facets.py +240 -0
  24. plot_io/figure.py +788 -0
  25. plot_io/formatters.py +97 -0
  26. plot_io/genome/__init__.py +17 -0
  27. plot_io/genome/adapters.py +356 -0
  28. plot_io/genome/combine.py +123 -0
  29. plot_io/genome/contacts.py +508 -0
  30. plot_io/genome/intervals.py +750 -0
  31. plot_io/genome/lanes.py +191 -0
  32. plot_io/genome/layout.py +184 -0
  33. plot_io/genome/links.py +364 -0
  34. plot_io/genome/pack.py +27 -0
  35. plot_io/genome/readers.py +145 -0
  36. plot_io/genome/regions.py +228 -0
  37. plot_io/genome/scale.py +349 -0
  38. plot_io/genome/series.py +344 -0
  39. plot_io/genome/signal.py +621 -0
  40. plot_io/genome/sites.py +782 -0
  41. plot_io/genome/sources.py +459 -0
  42. plot_io/genome/testing.py +161 -0
  43. plot_io/geometry.py +289 -0
  44. plot_io/groups.py +308 -0
  45. plot_io/guides.py +503 -0
  46. plot_io/helpers/__init__.py +17 -0
  47. plot_io/helpers/clustermap.py +202 -0
  48. plot_io/helpers/dendrogram.py +172 -0
  49. plot_io/helpers/grids.py +304 -0
  50. plot_io/helpers/qq.py +334 -0
  51. plot_io/helpers/raincloud.py +249 -0
  52. plot_io/helpers/tree.py +214 -0
  53. plot_io/helpers/volcano.py +505 -0
  54. plot_io/hints.py +125 -0
  55. plot_io/kernels.py +740 -0
  56. plot_io/labels.py +566 -0
  57. plot_io/layers/__init__.py +100 -0
  58. plot_io/layers/base.py +630 -0
  59. plot_io/layers/context.py +87 -0
  60. plot_io/layers/core.py +446 -0
  61. plot_io/layers/geoms.py +1362 -0
  62. plot_io/layers/grammar.py +586 -0
  63. plot_io/layers/paint.py +614 -0
  64. plot_io/layers/pipeline.py +170 -0
  65. plot_io/layers/positions.py +431 -0
  66. plot_io/layers/stats.py +1088 -0
  67. plot_io/layout.py +1381 -0
  68. plot_io/legend.py +605 -0
  69. plot_io/levels.py +509 -0
  70. plot_io/limits.py +124 -0
  71. plot_io/lines.py +42 -0
  72. plot_io/mapped.py +522 -0
  73. plot_io/markers.py +110 -0
  74. plot_io/markup.py +128 -0
  75. plot_io/numbers.py +354 -0
  76. plot_io/occupancy.py +295 -0
  77. plot_io/plot.py +597 -0
  78. plot_io/presets/__init__.py +51 -0
  79. plot_io/presets/_util.py +153 -0
  80. plot_io/presets/area.py +145 -0
  81. plot_io/presets/bar.py +261 -0
  82. plot_io/presets/box.py +400 -0
  83. plot_io/presets/catpoints.py +177 -0
  84. plot_io/presets/contour.py +353 -0
  85. plot_io/presets/curves.py +30 -0
  86. plot_io/presets/dots.py +355 -0
  87. plot_io/presets/ecdf.py +97 -0
  88. plot_io/presets/errorbar.py +106 -0
  89. plot_io/presets/errors.py +296 -0
  90. plot_io/presets/fill.py +74 -0
  91. plot_io/presets/heatmap.py +322 -0
  92. plot_io/presets/hexbin.py +198 -0
  93. plot_io/presets/hist.py +391 -0
  94. plot_io/presets/hist2d.py +87 -0
  95. plot_io/presets/kde.py +162 -0
  96. plot_io/presets/kde2d.py +147 -0
  97. plot_io/presets/line.py +143 -0
  98. plot_io/presets/pie.py +502 -0
  99. plot_io/presets/regression.py +115 -0
  100. plot_io/presets/scatter.py +194 -0
  101. plot_io/presets/stem.py +100 -0
  102. plot_io/presets/violin.py +323 -0
  103. plot_io/py.typed +0 -0
  104. plot_io/registry.py +82 -0
  105. plot_io/render.py +480 -0
  106. plot_io/rows.py +491 -0
  107. plot_io/scales.py +103 -0
  108. plot_io/seams.py +138 -0
  109. plot_io/settings.py +807 -0
  110. plot_io/shapes.py +66 -0
  111. plot_io/sharing.py +232 -0
  112. plot_io/sizemaps.py +97 -0
  113. plot_io/spec.py +240 -0
  114. plot_io/states.py +216 -0
  115. plot_io/styles/__init__.py +307 -0
  116. plot_io/styles/library.py +397 -0
  117. plot_io/tables.py +138 -0
  118. plot_io/text.py +412 -0
  119. plot_io/ticks.py +400 -0
  120. plot_io/times.py +287 -0
  121. plot_io/titles.py +225 -0
  122. plot_io/transform.py +114 -0
  123. plot_io/types.py +13604 -0
  124. plot_io/units.py +768 -0
  125. plot_io/validators.py +1029 -0
  126. plot_io/values.py +190 -0
  127. plot_io-0.12.0.dist-info/METADATA +215 -0
  128. plot_io-0.12.0.dist-info/RECORD +130 -0
  129. plot_io-0.12.0.dist-info/WHEEL +4 -0
  130. plot_io-0.12.0.dist-info/licenses/LICENSE +28 -0
plot_io/ticks.py ADDED
@@ -0,0 +1,400 @@
1
+ """Tick choice and tick labels.
2
+
3
+ Every function works on data values and returns :class:`Tick` lists; positions on the
4
+ page are the axis's business. Labels are strings or rich content (exponents).
5
+ """
6
+
7
+ import math
8
+ from collections.abc import Callable, Sequence
9
+ from typing import Any, NamedTuple
10
+
11
+ from .numbers import NumberStyle, format_axis
12
+ from .scales import Scale
13
+
14
+ __all__ = ["Tick", "linear_ticks", "nice_step", "numeric_ticks", "power_labels",
15
+ "step_values", "thin_by_position", "minor_linear", "exponent_label", "Every",
16
+ "every", "LOG_BASES"]
17
+
18
+ _MANTISSAS = (1.0, 2.0, 2.5, 5.0)
19
+
20
+
21
+ class Tick(NamedTuple):
22
+ value: float
23
+ label: Any # str, rich content, or None (unlabelled)
24
+ major: bool = True
25
+
26
+
27
+ def nice_step(raw: float) -> float:
28
+ """The smallest step 1, 2, 2.5 or 5 × 10^k that is at least ``raw``."""
29
+ if not raw > 0 or not math.isfinite(raw):
30
+ return 1.0
31
+ k = math.floor(math.log10(raw))
32
+ for m in _MANTISSAS + (10.0,):
33
+ s = m * 10.0 ** k
34
+ if s >= raw * (1 - 1e-9):
35
+ return s
36
+ return 10.0 ** (k + 1)
37
+
38
+
39
+ def _next_step(step: float) -> float:
40
+ k = math.floor(math.log10(step) + 1e-9)
41
+ m = step / 10.0 ** k
42
+ for c in _MANTISSAS + (10.0,):
43
+ if c > m * (1 + 1e-9):
44
+ return c * 10.0 ** k
45
+ return 2.0 * 10.0 ** (k + 1)
46
+
47
+
48
+ #: The most tick values one call makes.
49
+ MAX_TICKS = 100_000
50
+
51
+
52
+ def _values(lo: float, hi: float, step: float) -> list[float]:
53
+ eps = step * 1e-9
54
+ a, b = (lo - eps) / step, (hi + eps) / step
55
+ if not (math.isfinite(a) and math.isfinite(b)) or b - a > MAX_TICKS:
56
+ raise ValueError(f"ticks every {step:g} from {lo:g} to {hi:g}: more than "
57
+ f"{MAX_TICKS} ticks")
58
+ start = math.ceil(a)
59
+ end = math.floor(b)
60
+ digits = 2 - math.floor(math.log10(step)) # rounded relative to the step
61
+ return [round(i * step, digits) + 0.0 for i in range(start, end + 1)]
62
+
63
+
64
+ def linear_ticks(lo: float, hi: float, max_ticks: int,
65
+ fits: Callable[[list[float], float], bool] | None = None,
66
+ min_step: float = 0.0) -> tuple[list[float], float]:
67
+ """Tick values in [lo, hi]: the smallest nice step giving at most ``max_ticks``
68
+ ticks (and for which ``fits(values, step)`` holds); with ``min_step``, a whole multiple
69
+ of it (``1``: integers only). Returns ``(values, step)``."""
70
+ if hi < lo:
71
+ lo, hi = hi, lo
72
+ if hi == lo:
73
+ return [lo], 1.0
74
+ n = max(2, int(max_ticks))
75
+ step = max(nice_step((hi - lo) / n), min_step)
76
+ for _ in range(60):
77
+ whole = not min_step or abs(step / min_step - round(step / min_step)) < 1e-9
78
+ if whole:
79
+ vals = _values(lo, hi, step)
80
+ if len(vals) <= n and (fits is None or fits(vals, step)):
81
+ return vals, step
82
+ step = _next_step(step)
83
+ return _values(lo, hi, step), step
84
+
85
+
86
+ def minor_linear(lo: float, hi: float, step: float) -> list[float]:
87
+ k = math.floor(math.log10(step) + 1e-9)
88
+ m = round(step / 10.0 ** k, 6)
89
+ sub = step / (4 if m == 2.0 else 5)
90
+ return _values(lo, hi, sub)
91
+
92
+
93
+ # ---------------------------------------------------------------------------
94
+ # Ticks per scale
95
+ # ---------------------------------------------------------------------------
96
+
97
+ def exponent_label(base: str, k: int, style: NumberStyle | None = None) -> Any:
98
+ from .text import superscript
99
+ style = style or NumberStyle()
100
+ return style.wrap([base, superscript(style.exponent(k))])
101
+
102
+
103
+ def _int_powers(flo: float, fhi: float) -> list[int]:
104
+ return list(range(math.ceil(flo - 1e-9), math.floor(fhi + 1e-9) + 1))
105
+
106
+
107
+ def _thin_powers(ks: list[int], max_ticks: int, base: int = 1) -> tuple[list[int], int]:
108
+ """Every k-th power so that at most ``max_ticks`` remain (k a power of two when
109
+ ``base`` is 2, else any integer)."""
110
+ every = 1
111
+ while len([k for k in ks if k % every == 0]) > max(2, max_ticks):
112
+ every = every * 2 if base == 2 else every + 1
113
+ return [k for k in ks if k % every == 0], every
114
+
115
+
116
+ def _each(values: list[float], fmt: Any, style: NumberStyle) -> list[Any]:
117
+ """Labels formatted one by one (log and other non-linear ticks)."""
118
+ if fmt != "auto":
119
+ return format_axis(values, fmt, style, None)[0]
120
+ return [format_axis([v], "auto", style, None)[0][0] for v in values]
121
+
122
+
123
+ #: The label of each logarithmic base in power labels.
124
+ LOG_BASES = {math.e: "e", 2.0: "2", 10.0: "10"}
125
+
126
+
127
+ def power_labels(base: float, ks: list[int], fmt: Any = "auto",
128
+ style: NumberStyle | None = None) -> list[Any]:
129
+ """Labels of the powers ``base**k`` of a log scale: ``10ᵏ``; in base 2 the numbers up
130
+ to 2¹⁰, else ``2ᵏ``; ``eᵏ`` (``1`` at 0); in ``fmt`` when it is set."""
131
+ style = style or NumberStyle()
132
+ if fmt != "auto":
133
+ return _each([base ** k for k in ks], fmt, style)
134
+ out: list[Any] = []
135
+ for k in ks:
136
+ if base == 10.0:
137
+ out.append(exponent_label("10", k, style))
138
+ elif base == 2.0:
139
+ out.append(_each([base ** k], "auto", style)[0] if -10 <= k <= 10
140
+ else exponent_label("2", k, style))
141
+ else:
142
+ out.append(style.wrap("1") if k == 0
143
+ else exponent_label(LOG_BASES[base], k, style))
144
+ return out
145
+
146
+
147
+ def step_values(lo: float, hi: float, step: float) -> list[float]:
148
+ """The multiples of ``step`` from ``lo`` to ``hi`` (rounded relative to the step)."""
149
+ if not (step > 0 and math.isfinite(step)):
150
+ raise ValueError(f"expected a positive step, got {step!r}")
151
+ return _values(min(lo, hi), max(lo, hi), step)
152
+
153
+
154
+ class Every:
155
+ """Ticks every ``step`` (:func:`every`)."""
156
+
157
+ __slots__ = ("step", "unit")
158
+
159
+ def __init__(self, step: float, unit: str | None) -> None:
160
+ self.step, self.unit = step, unit
161
+
162
+ def __repr__(self) -> str:
163
+ if self.unit is None:
164
+ return f"every({self.step:g})"
165
+ return f"every({str(int(self.step)) + ' ' + self.unit + 's'!r})"
166
+
167
+ def __eq__(self, other: object) -> bool:
168
+ return isinstance(other, Every) and (self.step, self.unit) == (other.step, other.unit)
169
+
170
+ def __hash__(self) -> int:
171
+ return hash((self.step, self.unit))
172
+
173
+
174
+ #: Calendar units of :func:`every` and their size in the units of the time locator.
175
+ _UNITS = {"microsecond": ("microsecond", 1), "millisecond": ("microsecond", 1000),
176
+ "second": ("second", 1), "minute": ("minute", 1), "hour": ("hour", 1),
177
+ "day": ("day", 1), "week": ("day", 7), "month": ("month", 1), "year": ("year", 1)}
178
+
179
+
180
+ def every(step: Any) -> Every:
181
+ """Ticks every ``step`` for ``ticks=`` and ``minor_ticks=``: a number in axis units
182
+ (on a log axis in powers of its base: ``every(1)`` is every decade; on a category axis
183
+ every n-th category; on a time axis seconds, or a ``timedelta``), or a calendar step
184
+ on a time axis: ``"2 months"``, ``"week"``, ``"15 minutes"`` (units ``microsecond``,
185
+ ``millisecond``, ``second``, ``minute``, ``hour``, ``day``, ``week``, ``month``,
186
+ ``year``)."""
187
+ import datetime as dt
188
+ from .validators import real
189
+ if isinstance(step, dt.timedelta):
190
+ step = step.total_seconds()
191
+ if isinstance(step, str):
192
+ parts = step.split()
193
+ if len(parts) in (1, 2):
194
+ count = parts[0] if len(parts) == 2 else "1"
195
+ unit = parts[-1].lower()
196
+ unit = unit[:-1] if unit.endswith("s") and unit[:-1] in _UNITS else unit
197
+ if unit in _UNITS and count.isdigit() and int(count) > 0:
198
+ return Every(float(count), unit)
199
+ raise ValueError(f"every: expected a number or a calendar step such as "
200
+ f"'2 months' (units {', '.join(_UNITS)}), got {step!r}")
201
+ x = real(step)
202
+ if x is None or not (x > 0 and math.isfinite(x)):
203
+ raise ValueError(f"every: expected a positive number, got {step!r}")
204
+ return Every(x, None)
205
+
206
+
207
+ def _no_quarters(fits: Callable[[list[float], float], bool] | None
208
+ ) -> Callable[[list[float], float], bool]:
209
+ """``fits`` refusing steps of 2.5 × 10^k (labels with a set precision round them)."""
210
+ def out(vals: list[float], step: float) -> bool:
211
+ m = step / 10.0 ** math.floor(math.log10(step) + 1e-9)
212
+ return abs(m - 2.5) > 1e-6 and (fits is None or fits(vals, step))
213
+ return out
214
+
215
+
216
+ def numeric_ticks(scale: Scale, lo: float, hi: float, max_ticks: int, fmt: Any = "auto",
217
+ style: NumberStyle | None = None, sci_limits: tuple[int, int] = (-4, 5),
218
+ minor: bool = True, length: float = 0.0,
219
+ fits: Callable[[list[float], float], bool] | None = None,
220
+ min_step: float = 0.0) -> tuple[list[Tick], int]:
221
+ """Ticks of a numeric scale between data limits ``lo`` < ``hi``, labelled with the
222
+ number format ``fmt``; a linear scale's step is a whole multiple of ``min_step``.
223
+ Returns the ticks and the exponent of a common ×10ᵏ factor (0: none)."""
224
+ style = style or NumberStyle()
225
+ if hi < lo:
226
+ lo, hi = hi, lo
227
+ name = scale.name
228
+ flo, fhi = scale.f(lo), scale.f(hi)
229
+ base_num = scale.base
230
+ if base_num is not None:
231
+ ks = _int_powers(flo, fhi)
232
+ if base_num == 10.0 and 1 <= len(ks) < 3:
233
+ values = [m * 10.0 ** k for k in range(min(ks, default=0) - 1, max(ks, default=0) + 1)
234
+ for m in (1, 2, 5) if lo * (1 - 1e-9) <= m * 10.0 ** k <= hi * (1 + 1e-9)]
235
+ if len(values) >= 3 or len(values) > len(ks):
236
+ ticks = [Tick(v, s) for v, s in zip(values, _each(values, fmt, style))]
237
+ if minor:
238
+ ticks += [Tick(m * 10.0 ** k, None, False)
239
+ for k in range(min(ks) - 1, max(ks) + 1)
240
+ for m in (3, 4, 6, 7, 8, 9)
241
+ if lo <= m * 10.0 ** k <= hi]
242
+ return ticks, 0
243
+ if len(ks) >= 2:
244
+ kept, every = _thin_powers(ks, max_ticks, 2 if base_num == 2.0 else 1)
245
+ powers = dict(zip(kept, power_labels(base_num, kept, fmt, style)))
246
+ ticks = [Tick(base_num ** k, powers[k]) if k in powers
247
+ else Tick(base_num ** k, None, False) for k in ks]
248
+ if base_num == 10.0 and minor and every == 1 and len(ks) <= 8:
249
+ ticks += [Tick(m * 10.0 ** k, None, False) for k in range(ks[0] - 1, ks[-1] + 1)
250
+ for m in range(2, 10) if lo <= m * 10.0 ** k <= hi]
251
+ return ticks, 0
252
+ # Less than two powers in range: nice values in data space.
253
+ if name == "linear" or base_num is not None:
254
+ if style.precision is not None: # 0.25 at one digit would read 0.3
255
+ fits = _no_quarters(fits)
256
+ values, step = linear_ticks(lo, hi, max_ticks, fits,
257
+ min_step if name == "linear" else 0.0)
258
+ values = [v for v in values if scale.contains(v)]
259
+ labels, exp = format_axis(values, fmt, style, sci_limits)
260
+ return [Tick(v, s) for v, s in zip(values, labels)], exp
261
+ if name == "symlog":
262
+ return _symlog_ticks(scale, lo, hi, max_ticks, fmt, style, minor, length)
263
+ if name == "logit":
264
+ return _logit_ticks(scale, lo, hi, max_ticks, fmt, style, length)
265
+ # sqrt, exp and other monotone scales: the smallest nice step whose ticks are at least
266
+ # `length / max_ticks` apart on the page.
267
+ spacing = length / max(max_ticks, 1)
268
+
269
+ def spaced(vals: list[float], step: float) -> bool:
270
+ vals = [v for v in vals if scale.contains(v)]
271
+ if length <= 0 or len(vals) < 2 or fhi == flo:
272
+ return True
273
+ pos = [(scale.f(v) - flo) / (fhi - flo) * length for v in vals]
274
+ return min(abs(b - a) for a, b in zip(pos, pos[1:])) >= spacing - 1e-9
275
+
276
+ values, step = linear_ticks(lo, hi, max(2 * max_ticks, 4), spaced)
277
+ values = [v for v in values if scale.contains(v)]
278
+ return [Tick(v, s) for v, s in zip(values, _each(values, fmt, style))], 0
279
+
280
+
281
+ def thin_by_position(pos: Sequence[float], spacing: float) -> list[int]:
282
+ """Indices of increasing positions kept so that neighbours are at least ``spacing``
283
+ apart. Walks from the end where the scale stretches the data most (the sparse end),
284
+ always keeping both ends."""
285
+ n = len(pos)
286
+ if n <= 2:
287
+ return list(range(n))
288
+ order = list(range(n))
289
+ if pos[-1] - pos[-2] > pos[1] - pos[0]:
290
+ order.reverse() # sparse at the high end: walk from there
291
+ kept = [order[0]]
292
+ for i in order[1:-1]:
293
+ if abs(pos[i] - pos[kept[-1]]) >= spacing and abs(pos[order[-1]] - pos[i]) >= spacing:
294
+ kept.append(i)
295
+ kept.append(order[-1])
296
+ return sorted(kept)
297
+
298
+
299
+ def _thin(scale: Scale, values: list[float], lo: float, hi: float, max_ticks: int,
300
+ length: float) -> list[float]:
301
+ flo, fhi = scale.f(lo), scale.f(hi)
302
+ if length > 0 and len(values) > 2 and fhi != flo:
303
+ pos = [(scale.f(v) - flo) / (fhi - flo) * length for v in values]
304
+ keep = thin_by_position(pos, length / max(max_ticks, 1))
305
+ values = [values[i] for i in keep]
306
+ return values
307
+
308
+
309
+ def _thin_symlog(scale: Scale, values: list[float], lo: float, hi: float, max_ticks: int,
310
+ length: float) -> list[float]:
311
+ """Symlog ticks kept from 0 outwards on each side (0 and the decades nearest to it
312
+ first), neighbours at least ``length / max_ticks`` apart, both ends kept."""
313
+ flo, fhi = scale.f(lo), scale.f(hi)
314
+ if length <= 0 or len(values) <= 2 or fhi == flo:
315
+ return values
316
+ spacing = length / max(max_ticks, 1)
317
+ pos = {v: (scale.f(v) - flo) / (fhi - flo) * length for v in values}
318
+ center = min(values, key=abs)
319
+ kept = [center]
320
+ for side in ([v for v in values if v > center], [v for v in reversed(values) if v < center]):
321
+ last = center
322
+ for i, v in enumerate(side):
323
+ end = side[-1]
324
+ if v is end or (abs(pos[v] - pos[last]) >= spacing
325
+ and abs(pos[end] - pos[v]) >= spacing):
326
+ if v is end and abs(pos[v] - pos[last]) < spacing and last is not center:
327
+ kept.remove(last)
328
+ kept.append(v)
329
+ last = v
330
+ return sorted(kept)
331
+
332
+
333
+ def _symlog_ticks(scale: Scale, lo: float, hi: float, max_ticks: int, fmt: Any,
334
+ style: NumberStyle, minor: bool, length: float) -> tuple[list[Tick], int]:
335
+ """0, ±linthresh, then decades beyond; linear minor ticks inside the linear region."""
336
+ c = scale.options.get("linthresh", 1.0)
337
+ eps = (hi - lo) * 1e-9 + 1e-300
338
+ majors = [0.0] if lo - eps <= 0.0 <= hi + eps else []
339
+ for sign in (1.0, -1.0):
340
+ k = 0
341
+ while k < 400:
342
+ v = sign * c * 10.0 ** k
343
+ if not lo - eps <= v <= hi + eps:
344
+ if (sign > 0 and v > hi) or (sign < 0 and v < lo):
345
+ break
346
+ else:
347
+ majors.append(v)
348
+ k += 1
349
+ if len(majors) < 2: # inside the linear region: linear ticks
350
+ values, lin = linear_ticks(lo, hi, max_ticks)
351
+ labels = format_axis(values, fmt, style, None)[0] # together: shared decimals
352
+ ticks = [Tick(v, s) for v, s in zip(values, labels)]
353
+ if minor and len(values) >= 2:
354
+ ticks += [Tick(v, None, False) for v in minor_linear(lo, hi, lin)
355
+ if not any(abs(v - m) < lin * 1e-6 for m in values)]
356
+ return ticks, 0
357
+ majors = _thin_symlog(scale, sorted(set(majors)), lo, hi, max_ticks, length)
358
+ ticks = [Tick(v, s) for v, s in zip(majors, _each(majors, fmt, style))]
359
+ if minor:
360
+ have = set(majors)
361
+ step = _symlog_minor_step(scale, c)
362
+ if step is not None:
363
+ n = int(round(c / step))
364
+ for i in range(-n + 1, n):
365
+ v = i * step
366
+ if i and v not in have and lo <= v <= hi:
367
+ ticks.append(Tick(v, None, False))
368
+ return ticks, 0
369
+
370
+
371
+ #: Smallest gap between minor ticks in the linear region of a symlog axis, in scale units
372
+ #: (decades): the gap between minor ticks 4 and 5 of a log decade.
373
+ _SYMLOG_MINOR_GAP = math.log10(1.25)
374
+
375
+
376
+ def _symlog_minor_step(scale: Scale, c: float) -> float | None:
377
+ """The finest nice step (c/10, c/5, c/4, c/2) whose ticks in (−c, c) stay at least
378
+ ``_SYMLOG_MINOR_GAP`` apart on the axis, or ``None`` when no step fits."""
379
+ if 2.0 * scale.f(c) < 2.0 * _SYMLOG_MINOR_GAP:
380
+ return None
381
+ for div in (10, 5, 4, 2):
382
+ step = c / div
383
+ pos = [scale.f(i * step) for i in range(0, div + 1)]
384
+ if min(b - a for a, b in zip(pos, pos[1:])) >= _SYMLOG_MINOR_GAP:
385
+ return step
386
+ return None
387
+
388
+
389
+ def _logit_ticks(scale: Scale, lo: float, hi: float, max_ticks: int, fmt: Any,
390
+ style: NumberStyle, length: float) -> tuple[list[Tick], int]:
391
+ """0.5, then 0.1/0.9, 0.01/0.99, … inside the limits."""
392
+ values = [0.5]
393
+ for k in range(1, 16):
394
+ values += [10.0 ** -k, 1.0 - 10.0 ** -k]
395
+ values = sorted(v for v in values if lo <= v <= hi)
396
+ if len(values) < 2:
397
+ values, _ = linear_ticks(lo, hi, max_ticks)
398
+ values = [v for v in values if 0 < v < 1]
399
+ values = _thin(scale, values, lo, hi, max_ticks, length)
400
+ return [Tick(v, s) for v, s in zip(values, _each(values, fmt, style))], 0
plot_io/times.py ADDED
@@ -0,0 +1,287 @@
1
+ """Dates and times on axes.
2
+
3
+ Values (``datetime``, ``date``, numpy ``datetime64`` arrays, pandas timestamps) become
4
+ float seconds since 1970-01-01 UTC, so scales, limits and the C mapping are unchanged; the
5
+ axis (``scale="time"``, set by date data) chooses calendar-aware ticks and concise labels.
6
+ """
7
+
8
+ import array
9
+ import calendar
10
+ import datetime as dt
11
+ import math
12
+ from typing import Any
13
+
14
+ __all__ = ["is_time_like", "to_seconds", "seconds", "time_ticks", "time_labels", "time_unit",
15
+ "from_seconds", "axis_value", "calendar_ticks"]
16
+
17
+ _UTC = dt.timezone.utc
18
+
19
+
20
+ def _first(v: Any) -> Any:
21
+ for x in v:
22
+ if x is not None and not (isinstance(x, float) and math.isnan(x)):
23
+ return x
24
+ return None
25
+
26
+
27
+ def is_time_like(v: Any) -> bool:
28
+ """Whether ``v`` is a date or time value, or a sequence or array of them."""
29
+ if isinstance(v, (dt.date, dt.datetime)):
30
+ return True
31
+ kind = getattr(getattr(v, "dtype", None), "kind", None)
32
+ if kind == "M":
33
+ return True
34
+ if isinstance(v, (list, tuple)) and v:
35
+ return isinstance(_first(v), (dt.date, dt.datetime))
36
+ return False
37
+
38
+
39
+ def seconds(x: Any) -> float:
40
+ """Seconds since the epoch of one value (naive datetimes are UTC)."""
41
+ if x is None or (isinstance(x, float) and math.isnan(x)):
42
+ return math.nan
43
+ if isinstance(x, dt.datetime):
44
+ if x.tzinfo is not None:
45
+ return x.timestamp()
46
+ return calendar.timegm(x.timetuple()) + x.microsecond / 1e6
47
+ if isinstance(x, dt.date):
48
+ return float(calendar.timegm(x.timetuple()))
49
+ if hasattr(x, "astype") and getattr(getattr(x, "dtype", None), "kind", None) == "M":
50
+ n = int(x.astype("datetime64[ns]").astype("int64"))
51
+ return math.nan if n == -(2 ** 63) else n / 1e9
52
+ if isinstance(x, (int, float)):
53
+ return float(x)
54
+ raise ValueError(f"not a date or time: {x!r}")
55
+
56
+
57
+ def axis_value(v: Any) -> float:
58
+ """A position on an axis as a float: a real number, a date or time (seconds since the
59
+ epoch), a numpy ``datetime64`` or ISO 8601 text."""
60
+ from .validators import real
61
+ x = real(v)
62
+ if x is not None:
63
+ return x
64
+ if isinstance(v, (dt.date, dt.datetime)) or getattr(
65
+ getattr(v, "dtype", None), "kind", None) == "M":
66
+ return seconds(v)
67
+ if isinstance(v, str):
68
+ try:
69
+ return seconds(dt.datetime.fromisoformat(v.strip()))
70
+ except ValueError:
71
+ pass
72
+ raise ValueError(f"expected a number, a date or ISO 8601 text, got {v!r}")
73
+
74
+
75
+ def to_seconds(v: Any) -> tuple[array.array, Any]:
76
+ """Seconds since the epoch of every value, and the time zone of the first aware
77
+ value (``None``: naive, shown as UTC)."""
78
+ kind = getattr(getattr(v, "dtype", None), "kind", None)
79
+ if kind == "M":
80
+ ns = v.astype("datetime64[ns]").astype("int64").tolist()
81
+ nat = -(2 ** 63)
82
+ return array.array("d", (math.nan if n == nat else n / 1e9 for n in ns)), None
83
+ if isinstance(v, (dt.date, dt.datetime)):
84
+ v = [v]
85
+ first = _first(v)
86
+ tz = first.tzinfo if isinstance(first, dt.datetime) else None
87
+ return array.array("d", (seconds(x) for x in v)), tz
88
+
89
+
90
+ #: The first and last times a ``datetime`` holds, in seconds.
91
+ _FIRST = seconds(dt.datetime.min)
92
+ _LAST = seconds(dt.datetime.max)
93
+
94
+
95
+ def from_seconds(s: float, tz: Any) -> dt.datetime:
96
+ """The datetime of ``s`` seconds in zone ``tz`` (naive UTC when ``tz`` is ``None``);
97
+ times before year 1 or after 9999 are taken at those ends."""
98
+ s = min(max(s, _FIRST), _LAST)
99
+ try:
100
+ d = dt.datetime.fromtimestamp(s, tz or _UTC)
101
+ except (ValueError, OverflowError): # the zone's offset passes an end
102
+ d = (dt.datetime.min if s < 0 else dt.datetime.max).replace(tzinfo=tz or _UTC)
103
+ return d if tz is not None else d.replace(tzinfo=None)
104
+
105
+
106
+ def time_unit(s: float, tz: Any) -> float:
107
+ """The calendar unit of a time (s): a day at midnight in zone ``tz``, else an hour,
108
+ a minute or a second at a whole one, else a millisecond."""
109
+ d = from_seconds(s, tz)
110
+ if d.microsecond:
111
+ return 1e-3
112
+ if d.second:
113
+ return 1.0
114
+ if d.minute:
115
+ return 60.0
116
+ if d.hour:
117
+ return 3600.0
118
+ return 86400.0
119
+
120
+
121
+ def _epoch(d: dt.datetime, tz: Any) -> float:
122
+ return d.timestamp() if tz is not None else seconds(d)
123
+
124
+
125
+ #: (unit, step, approximate seconds) candidates, finest first.
126
+ _STEPS = ([("microsecond", s, s * 1e-6) for s in (1, 2, 5, 10, 20, 50, 100, 200, 500, 1000,
127
+ 2000, 5000, 10000, 20000, 50000, 100000,
128
+ 200000, 500000)]
129
+ + [("second", s, s) for s in (1, 2, 5, 10, 15, 30)]
130
+ + [("minute", s, 60 * s) for s in (1, 2, 5, 10, 15, 30)]
131
+ + [("hour", s, 3600 * s) for s in (1, 2, 3, 6, 12)]
132
+ + [("day", s, 86400 * s) for s in (1, 2, 7, 14)]
133
+ + [("month", s, 2629746 * s) for s in (1, 2, 3, 6)]
134
+ + [("year", s, 31556952 * s) for s in (1, 2, 5, 10, 20, 25, 50, 100, 200, 250, 500,
135
+ 1000)])
136
+
137
+
138
+ def _floor(d: dt.datetime, unit: str, step: int) -> dt.datetime:
139
+ if unit == "year": # year 0 does not exist: the first multiple
140
+ return d.replace(year=max(d.year - d.year % step, min(step, 9999)), month=1, day=1,
141
+ hour=0, minute=0, second=0, microsecond=0)
142
+ if unit == "month":
143
+ m = (d.month - 1) - (d.month - 1) % step + 1
144
+ return d.replace(month=m, day=1, hour=0, minute=0, second=0, microsecond=0)
145
+ if unit == "day":
146
+ return d.replace(hour=0, minute=0, second=0, microsecond=0)
147
+ if unit == "hour":
148
+ return d.replace(hour=d.hour - d.hour % step, minute=0, second=0, microsecond=0)
149
+ if unit == "minute":
150
+ return d.replace(minute=d.minute - d.minute % step, second=0, microsecond=0)
151
+ if unit == "second":
152
+ return d.replace(second=d.second - d.second % step, microsecond=0)
153
+ return d.replace(microsecond=0)
154
+
155
+
156
+ def _add(d: dt.datetime, unit: str, step: int) -> dt.datetime:
157
+ if unit == "year":
158
+ return d.replace(year=d.year + step)
159
+ if unit == "month":
160
+ m = d.month - 1 + step
161
+ return d.replace(year=d.year + m // 12, month=m % 12 + 1)
162
+ delta = {"hour": dt.timedelta(hours=step), "minute": dt.timedelta(minutes=step),
163
+ "second": dt.timedelta(seconds=step),
164
+ "microsecond": dt.timedelta(microseconds=step),
165
+ "day": dt.timedelta(days=1)}[unit]
166
+ return d + delta
167
+
168
+
169
+ def _candidates(lo: float, hi: float, unit: str, step: int, tz: Any) -> list[float]:
170
+ start = _floor(from_seconds(lo, tz), unit, step)
171
+ out: list[float] = []
172
+ d = start
173
+ guard = 0
174
+ while guard < 100000:
175
+ guard += 1
176
+ s = _epoch(d, tz)
177
+ if s > hi + 1e-9:
178
+ break
179
+ keep = unit != "day" or (d.toordinal() - 1) % step == 0 # every step-th day
180
+ if keep and s >= lo - 1e-9:
181
+ out.append(s)
182
+ try:
183
+ d = _add(d, unit, step)
184
+ except (ValueError, OverflowError): # past year 9999
185
+ break
186
+ return out
187
+
188
+
189
+ def calendar_ticks(lo: float, hi: float, unit: str, step: int, tz: Any) -> list[float]:
190
+ """Times from ``lo`` to ``hi`` (seconds) every ``step`` calendar ``unit`` (``year``,
191
+ ``month``, ``day``, ``hour``, ``minute``, ``second``, ``microsecond``) in zone
192
+ ``tz``."""
193
+ from .ticks import MAX_TICKS
194
+ if hi < lo:
195
+ lo, hi = hi, lo
196
+ approx = next(a / s for u, s, a in _STEPS if u == unit) * step
197
+ if (hi - lo) / approx > MAX_TICKS:
198
+ raise ValueError(f"ticks every {step} {unit}s: more than {MAX_TICKS} ticks")
199
+ return _candidates(lo, hi, unit, step, tz)
200
+
201
+
202
+ def time_ticks(lo: float, hi: float, max_ticks: int, tz: Any,
203
+ fmt: Any = "auto") -> tuple[list[tuple[float, str]], str | None]:
204
+ """Ticks between ``lo`` and ``hi`` seconds: ``[(value, label)]`` and the offset text
205
+ (the larger unit common to every tick: ``"Mar 2026"``)."""
206
+ if hi < lo:
207
+ lo, hi = hi, lo
208
+ span = max(hi - lo, 1e-6)
209
+ unit = "year"
210
+ ticks: list[float] = []
211
+ for u, s, approx in _STEPS:
212
+ if span / approx > max_ticks * 1.5:
213
+ continue
214
+ cand = _candidates(lo, hi, u, s, tz)
215
+ if 2 <= len(cand) <= max_ticks or (len(cand) <= max_ticks and u == "year"):
216
+ unit, ticks = u, cand
217
+ break
218
+ if not ticks:
219
+ ticks = [lo, hi]
220
+ unit = "second" if span < 86400 else "day"
221
+ dts = [from_seconds(t, tz) for t in ticks]
222
+ if isinstance(fmt, str) and "%" in fmt:
223
+ return [(t, d.strftime(fmt)) for t, d in zip(ticks, dts)], None
224
+ if callable(fmt):
225
+ return [(t, str(fmt(d))) for t, d in zip(ticks, dts)], None
226
+ return _concise(ticks, dts, unit, from_seconds((lo + hi) / 2, tz))
227
+
228
+
229
+ def time_labels(values: list[float], tz: Any, fmt: Any = "auto"
230
+ ) -> tuple[list[str], str | None]:
231
+ """Labels of given tick times (seconds) and the offset text, as :func:`time_ticks`
232
+ writes them: concise labels in the finest calendar unit the ticks need."""
233
+ dts = [from_seconds(t, tz) for t in values]
234
+ if isinstance(fmt, str) and "%" in fmt:
235
+ return [d.strftime(fmt) for d in dts], None
236
+ if callable(fmt):
237
+ return [str(fmt(d)) for d in dts], None
238
+ if not dts:
239
+ return [], None
240
+ if any(d.microsecond for d in dts):
241
+ unit = "microsecond"
242
+ elif any(d.second for d in dts):
243
+ unit = "second"
244
+ elif any(d.hour or d.minute for d in dts):
245
+ unit = "minute"
246
+ elif any(d.day != 1 for d in dts):
247
+ unit = "day"
248
+ elif any(d.month != 1 for d in dts):
249
+ unit = "month"
250
+ else:
251
+ unit = "year"
252
+ pairs, offset = _concise(list(values), dts, unit, from_seconds(
253
+ (min(values) + max(values)) / 2, tz))
254
+ return [lab for _, lab in pairs], offset
255
+
256
+
257
+ def _concise(ticks: list[float], dts: list[dt.datetime], unit: str, mid: dt.datetime
258
+ ) -> tuple[list[tuple[float, str]], str | None]:
259
+ """Concise labels of the ticks of ``unit`` and the offset text, the date part common
260
+ to the ticks written from the axis' middle (``mid``)."""
261
+ same_year = len({d.year for d in dts}) == 1
262
+ same_day = len({d.date() for d in dts}) == 1
263
+ if unit == "year":
264
+ return [(t, d.strftime("%Y")) for t, d in zip(ticks, dts)], None
265
+ if unit == "month":
266
+ labels = [d.strftime("%Y") if d.month == 1 and not same_year else d.strftime("%b")
267
+ for d in dts]
268
+ return list(zip(ticks, labels)), (mid.strftime("%Y") if same_year else None)
269
+ if unit == "day":
270
+ labels = [d.strftime("%b %d").replace(" 0", " ") for d in dts]
271
+ return list(zip(ticks, labels)), (mid.strftime("%Y") if same_year else None)
272
+ if unit in ("hour", "minute"):
273
+ labels = [d.strftime("%H:%M") if (d.hour, d.minute) != (0, 0) or same_day
274
+ else d.strftime("%b %d").replace(" 0", " ") for d in dts]
275
+ off = mid.strftime("%b %d, %Y").replace(" 0", " ") if same_day else (
276
+ mid.strftime("%Y") if same_year else None)
277
+ return list(zip(ticks, labels)), off
278
+ if unit == "second":
279
+ labels = [d.strftime("%H:%M:%S") for d in dts]
280
+ off = mid.strftime("%b %d, %Y").replace(" 0", " ") if same_day else None
281
+ return list(zip(ticks, labels)), off
282
+ digits = 6
283
+ while digits > 0 and all(d.microsecond % (10 ** (7 - digits)) == 0 for d in dts):
284
+ digits -= 1
285
+ labels = [d.strftime("%S") + ("." + f"{d.microsecond:06d}"[:digits] if digits else "")
286
+ for d in dts]
287
+ return list(zip(ticks, labels)), mid.strftime("%b %d, %Y %H:%M").replace(" 0", " ", 1)