plotille 6.0.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.
Potentially problematic release.
This version of plotille might be problematic. Click here for more details.
- plotille/__init__.py +41 -0
- plotille/_canvas.py +443 -0
- plotille/_cmaps.py +124 -0
- plotille/_cmaps_data.py +1601 -0
- plotille/_colors.py +379 -0
- plotille/_data_metadata.py +103 -0
- plotille/_dots.py +202 -0
- plotille/_figure.py +982 -0
- plotille/_figure_data.py +295 -0
- plotille/_graphs.py +373 -0
- plotille/_input_formatter.py +251 -0
- plotille/_util.py +92 -0
- plotille/data.py +100 -0
- plotille-6.0.0.dist-info/METADATA +644 -0
- plotille-6.0.0.dist-info/RECORD +16 -0
- plotille-6.0.0.dist-info/WHEEL +4 -0
plotille/_figure.py
ADDED
|
@@ -0,0 +1,982 @@
|
|
|
1
|
+
# The MIT License
|
|
2
|
+
|
|
3
|
+
# Copyright (c) 2017 - 2025 Tammo Ippen, tammo.ippen@posteo.de
|
|
4
|
+
|
|
5
|
+
# Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
# of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
# in the Software without restriction, including without limitation the rights
|
|
8
|
+
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
# copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
# furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
# The above copyright notice and this permission notice shall be included in
|
|
13
|
+
# all copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
|
|
21
|
+
# THE SOFTWARE.
|
|
22
|
+
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
from collections.abc import Callable, Iterator, Sequence
|
|
26
|
+
from datetime import timedelta, tzinfo
|
|
27
|
+
from itertools import cycle
|
|
28
|
+
|
|
29
|
+
if sys.version_info >= (3, 11):
|
|
30
|
+
from typing import Any, Final, Literal, NotRequired, TypedDict
|
|
31
|
+
else:
|
|
32
|
+
from typing import Any, Final, Literal, TypedDict
|
|
33
|
+
|
|
34
|
+
from typing_extensions import NotRequired
|
|
35
|
+
|
|
36
|
+
from ._canvas import Canvas
|
|
37
|
+
from ._cmaps import Colormap
|
|
38
|
+
from ._colors import ColorDefinition, ColorMode, color, rgb2byte
|
|
39
|
+
from ._data_metadata import DataMetadata
|
|
40
|
+
from ._figure_data import Heat, HeatInput, Histogram, Plot, Span, Text
|
|
41
|
+
from ._input_formatter import Converter, Formatter, InputFormatter
|
|
42
|
+
from ._util import DataValue, DataValues
|
|
43
|
+
|
|
44
|
+
"""Figure class for composing plots.
|
|
45
|
+
|
|
46
|
+
Architecture Note:
|
|
47
|
+
------------------
|
|
48
|
+
The Figure class manages plot composition and rendering. It works internally
|
|
49
|
+
with normalized float values:
|
|
50
|
+
|
|
51
|
+
- All limit calculations use float
|
|
52
|
+
- Axis generation uses float
|
|
53
|
+
- Canvas operations use float
|
|
54
|
+
|
|
55
|
+
The public API (plot, scatter, histogram, text methods) accepts both
|
|
56
|
+
numeric and datetime. Conversion to float happens in the data container
|
|
57
|
+
classes (Plot, Text, Histogram).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
# TODO documentation!!!
|
|
61
|
+
# TODO tests
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _ColorKwargs(TypedDict):
|
|
65
|
+
fg: NotRequired[ColorDefinition]
|
|
66
|
+
bg: NotRequired[ColorDefinition]
|
|
67
|
+
mode: ColorMode
|
|
68
|
+
no_color: NotRequired[bool]
|
|
69
|
+
full_reset: NotRequired[bool]
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class Figure:
|
|
73
|
+
"""Figure class to compose multiple plots.
|
|
74
|
+
|
|
75
|
+
Within a Figure you can easily compose many plots, assign labels to plots
|
|
76
|
+
and define the properties of the underlying Canvas. Possible properties that
|
|
77
|
+
can be defined are:
|
|
78
|
+
|
|
79
|
+
width, height: int Define the number of characters in X / Y direction
|
|
80
|
+
which are used for plotting.
|
|
81
|
+
x_limits: DataValue Define the X limits of the reference coordinate system,
|
|
82
|
+
that will be plotted.
|
|
83
|
+
y_limits: DataValue Define the Y limits of the reference coordinate system,
|
|
84
|
+
that will be plotted.
|
|
85
|
+
color_mode: str Define the used color mode. See `plotille.color()`.
|
|
86
|
+
with_colors: bool Define, whether to use colors at all.
|
|
87
|
+
background: ColorDefinition Define the background color.
|
|
88
|
+
x_label, y_label: str Define the X / Y axis label.
|
|
89
|
+
"""
|
|
90
|
+
|
|
91
|
+
_COLOR_SEQ: Final[list[dict[ColorMode, ColorDefinition]]] = [
|
|
92
|
+
{"names": "white", "rgb": (255, 255, 255), "byte": rgb2byte(255, 255, 255)},
|
|
93
|
+
{"names": "red", "rgb": (255, 0, 0), "byte": rgb2byte(255, 0, 0)},
|
|
94
|
+
{"names": "green", "rgb": (0, 255, 0), "byte": rgb2byte(0, 255, 0)},
|
|
95
|
+
{"names": "yellow", "rgb": (255, 255, 0), "byte": rgb2byte(255, 255, 0)},
|
|
96
|
+
{"names": "blue", "rgb": (0, 0, 255), "byte": rgb2byte(0, 0, 255)},
|
|
97
|
+
{"names": "magenta", "rgb": (255, 0, 255), "byte": rgb2byte(255, 0, 255)},
|
|
98
|
+
{"names": "cyan", "rgb": (0, 255, 255), "byte": rgb2byte(0, 255, 255)},
|
|
99
|
+
]
|
|
100
|
+
|
|
101
|
+
def __init__(self) -> None:
|
|
102
|
+
self._color_seq: Iterator[dict[ColorMode, ColorDefinition]] = iter(
|
|
103
|
+
cycle(Figure._COLOR_SEQ)
|
|
104
|
+
)
|
|
105
|
+
self._width: int | None = None
|
|
106
|
+
self._height: int | None = None
|
|
107
|
+
self._x_min: float | None = None
|
|
108
|
+
self._x_max: float | None = None
|
|
109
|
+
self._y_min: float | None = None
|
|
110
|
+
self._y_max: float | None = None
|
|
111
|
+
self._color_kwargs: _ColorKwargs = {"mode": "names"}
|
|
112
|
+
self._with_colors: bool = True
|
|
113
|
+
self._origin: bool = True
|
|
114
|
+
self.linesep: str = os.linesep
|
|
115
|
+
self.background: ColorDefinition = None
|
|
116
|
+
self.x_label: str = "X"
|
|
117
|
+
self.y_label: str = "Y"
|
|
118
|
+
# min, max -> value
|
|
119
|
+
self.y_ticks_fkt: Callable[[DataValue, DataValue], DataValue | str] | None = (
|
|
120
|
+
None
|
|
121
|
+
)
|
|
122
|
+
self.x_ticks_fkt: Callable[[DataValue, DataValue], DataValue | str] | None = (
|
|
123
|
+
None
|
|
124
|
+
)
|
|
125
|
+
self._plots: list[Plot | Histogram] = []
|
|
126
|
+
self._texts: list[Text] = []
|
|
127
|
+
self._spans: list[Span] = []
|
|
128
|
+
self._heats: list[Heat] = []
|
|
129
|
+
self._in_fmt: InputFormatter = InputFormatter()
|
|
130
|
+
|
|
131
|
+
# Metadata for axis display formatting
|
|
132
|
+
self._x_display_metadata: DataMetadata | None = None
|
|
133
|
+
self._y_display_metadata: DataMetadata | None = None
|
|
134
|
+
self._x_display_timezone_override: tzinfo | None = None
|
|
135
|
+
self._y_display_timezone_override: tzinfo | None = None
|
|
136
|
+
|
|
137
|
+
@property
|
|
138
|
+
def width(self) -> int:
|
|
139
|
+
if self._width is not None:
|
|
140
|
+
return self._width
|
|
141
|
+
return 80
|
|
142
|
+
|
|
143
|
+
@width.setter
|
|
144
|
+
def width(self, value: int) -> None:
|
|
145
|
+
if not (isinstance(value, int) and value > 0):
|
|
146
|
+
raise ValueError(f"Invalid width: {value}")
|
|
147
|
+
self._width = value
|
|
148
|
+
|
|
149
|
+
@property
|
|
150
|
+
def height(self) -> int:
|
|
151
|
+
if self._height is not None:
|
|
152
|
+
return self._height
|
|
153
|
+
return 40
|
|
154
|
+
|
|
155
|
+
@height.setter
|
|
156
|
+
def height(self, value: int) -> None:
|
|
157
|
+
if not (isinstance(value, int) and value > 0):
|
|
158
|
+
raise ValueError(f"Invalid height: {value}")
|
|
159
|
+
self._height = value
|
|
160
|
+
|
|
161
|
+
@property
|
|
162
|
+
def color_mode(self) -> ColorMode:
|
|
163
|
+
return self._color_kwargs["mode"]
|
|
164
|
+
|
|
165
|
+
@color_mode.setter
|
|
166
|
+
def color_mode(self, value: ColorMode) -> None:
|
|
167
|
+
if value not in ("names", "byte", "rgb"):
|
|
168
|
+
raise ValueError("Only supports: names, byte, rgb!")
|
|
169
|
+
if self._plots != []:
|
|
170
|
+
raise RuntimeError("Change color mode only, when no plots are prepared.")
|
|
171
|
+
self._color_kwargs["mode"] = value
|
|
172
|
+
|
|
173
|
+
@property
|
|
174
|
+
def color_full_reset(self) -> bool:
|
|
175
|
+
return self._color_kwargs.get("full_reset", True)
|
|
176
|
+
|
|
177
|
+
@color_full_reset.setter
|
|
178
|
+
def color_full_reset(self, value: bool) -> None:
|
|
179
|
+
if not isinstance(value, bool):
|
|
180
|
+
raise TypeError("Only supports bool.")
|
|
181
|
+
self._color_kwargs["full_reset"] = value
|
|
182
|
+
|
|
183
|
+
@property
|
|
184
|
+
def with_colors(self) -> bool:
|
|
185
|
+
"""Whether to plot with or without color."""
|
|
186
|
+
return self._with_colors
|
|
187
|
+
|
|
188
|
+
@with_colors.setter
|
|
189
|
+
def with_colors(self, value: bool) -> None:
|
|
190
|
+
if not isinstance(value, bool):
|
|
191
|
+
raise TypeError(f'Only bool allowed: "{value}"')
|
|
192
|
+
self._with_colors = value
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def origin(self) -> bool:
|
|
196
|
+
"""Show or not show the origin in the plot."""
|
|
197
|
+
return self._origin
|
|
198
|
+
|
|
199
|
+
@origin.setter
|
|
200
|
+
def origin(self, value: bool) -> None:
|
|
201
|
+
if not isinstance(value, bool):
|
|
202
|
+
raise TypeError(f"Invalid origin: {value}")
|
|
203
|
+
self._origin = value
|
|
204
|
+
|
|
205
|
+
def _aggregate_metadata(self, is_height: bool) -> DataMetadata | None:
|
|
206
|
+
"""Aggregate metadata from all plots for one axis.
|
|
207
|
+
|
|
208
|
+
Determines whether the axis should display as numeric or datetime,
|
|
209
|
+
and validates that all plots have compatible types.
|
|
210
|
+
|
|
211
|
+
Args:
|
|
212
|
+
is_height: True for Y-axis, False for X-axis
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
DataMetadata for the axis (with display timezone), or None if no plots
|
|
216
|
+
|
|
217
|
+
Raises:
|
|
218
|
+
ValueError: If plots have incompatible types on same axis
|
|
219
|
+
"""
|
|
220
|
+
# Collect metadata from all plots
|
|
221
|
+
metadatas = []
|
|
222
|
+
for p in self._plots + self._texts:
|
|
223
|
+
if is_height:
|
|
224
|
+
metadatas.append(p.Y_metadata)
|
|
225
|
+
else:
|
|
226
|
+
metadatas.append(p.X_metadata)
|
|
227
|
+
|
|
228
|
+
if not metadatas:
|
|
229
|
+
# No plots yet, no metadata to aggregate
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
datetime_flags = {m.is_datetime for m in metadatas}
|
|
233
|
+
if len(datetime_flags) > 1:
|
|
234
|
+
axis_name = "Y" if is_height else "X"
|
|
235
|
+
raise ValueError(
|
|
236
|
+
f"Cannot mix numeric and datetime values on {axis_name}-axis. "
|
|
237
|
+
f"All plots on an axis must use the same data type."
|
|
238
|
+
)
|
|
239
|
+
|
|
240
|
+
if not metadatas[0].is_datetime:
|
|
241
|
+
return DataMetadata(is_datetime=False, timezone=None)
|
|
242
|
+
|
|
243
|
+
timezones = {m.timezone for m in metadatas}
|
|
244
|
+
has_naive = None in timezones
|
|
245
|
+
has_aware = len(timezones - {None}) > 0
|
|
246
|
+
|
|
247
|
+
# Cannot mix naive and aware datetime
|
|
248
|
+
if has_naive and has_aware:
|
|
249
|
+
axis_name = "Y" if is_height else "X"
|
|
250
|
+
raise ValueError(
|
|
251
|
+
f"Cannot mix timezone-naive and timezone-aware datetime on {axis_name}-axis. "
|
|
252
|
+
f"Either all datetimes must have timezones or none must have timezones. "
|
|
253
|
+
f"Found: {timezones}"
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
# Pick first encountered timezone as default
|
|
257
|
+
# (User can override with set_x_display_timezone/set_y_display_timezone)
|
|
258
|
+
display_timezone = metadatas[0].timezone
|
|
259
|
+
|
|
260
|
+
return DataMetadata(is_datetime=True, timezone=display_timezone)
|
|
261
|
+
|
|
262
|
+
def set_x_display_timezone(self, tz: tzinfo | None) -> None:
|
|
263
|
+
"""Set display timezone for X-axis labels.
|
|
264
|
+
|
|
265
|
+
Use this when you have datetime data with multiple timezones and want
|
|
266
|
+
to display the axis in a specific timezone.
|
|
267
|
+
|
|
268
|
+
Args:
|
|
269
|
+
tz: Target timezone (e.g., ZoneInfo("America/New_York"), timezone.utc)
|
|
270
|
+
or None for naive datetime display
|
|
271
|
+
|
|
272
|
+
Example:
|
|
273
|
+
from zoneinfo import ZoneInfo
|
|
274
|
+
fig.set_x_display_timezone(ZoneInfo("America/New_York"))
|
|
275
|
+
"""
|
|
276
|
+
self._x_display_timezone_override = tz
|
|
277
|
+
|
|
278
|
+
def set_y_display_timezone(self, tz: tzinfo | None) -> None:
|
|
279
|
+
"""Set display timezone for Y-axis labels.
|
|
280
|
+
|
|
281
|
+
Use this when you have datetime data with multiple timezones and want
|
|
282
|
+
to display the axis in a specific timezone.
|
|
283
|
+
|
|
284
|
+
Args:
|
|
285
|
+
tz: Target timezone (e.g., ZoneInfo("America/New_York"), timezone.utc)
|
|
286
|
+
or None for naive datetime display
|
|
287
|
+
|
|
288
|
+
Example:
|
|
289
|
+
from zoneinfo import ZoneInfo
|
|
290
|
+
fig.set_y_display_timezone(ZoneInfo("UTC"))
|
|
291
|
+
"""
|
|
292
|
+
self._y_display_timezone_override = tz
|
|
293
|
+
|
|
294
|
+
def register_label_formatter(self, type_: type[Any], formatter: Formatter) -> None:
|
|
295
|
+
"""Register a formatter for labels of a certain type.
|
|
296
|
+
|
|
297
|
+
See `plotille._input_formatter` for examples.
|
|
298
|
+
|
|
299
|
+
Parameters
|
|
300
|
+
----------
|
|
301
|
+
type_
|
|
302
|
+
A python type, that can be used for isinstance tests.
|
|
303
|
+
formatter: (val: type_, chars: int, delta, left: bool = False) -> str
|
|
304
|
+
Function that formats `val` into a string.
|
|
305
|
+
chars: int => number of chars you should fill
|
|
306
|
+
delta => the difference between the smallest and largest X/Y value
|
|
307
|
+
left: bool => align left or right.
|
|
308
|
+
"""
|
|
309
|
+
self._in_fmt.register_formatter(type_, formatter)
|
|
310
|
+
|
|
311
|
+
def register_float_converter(self, type_: type[Any], converter: Converter) -> None:
|
|
312
|
+
"""Register a converter from some type_ to float.
|
|
313
|
+
|
|
314
|
+
See `plotille._input_formatter` for examples.
|
|
315
|
+
|
|
316
|
+
Parameters
|
|
317
|
+
----------
|
|
318
|
+
type_
|
|
319
|
+
A python type, that can be used for isinstance tests.
|
|
320
|
+
formatter: (val: type_) -> float
|
|
321
|
+
Function that formats `val` into a float.
|
|
322
|
+
"""
|
|
323
|
+
self._in_fmt.register_converter(type_, converter)
|
|
324
|
+
|
|
325
|
+
def x_limits(self) -> tuple[float, float]:
|
|
326
|
+
"""Get the X-axis limits as normalized floats."""
|
|
327
|
+
return self._limits(self._x_min, self._x_max, False)
|
|
328
|
+
|
|
329
|
+
def set_x_limits(
|
|
330
|
+
self, min_: DataValue | None = None, max_: DataValue | None = None
|
|
331
|
+
) -> None:
|
|
332
|
+
"""Set min and max X values for displaying.
|
|
333
|
+
|
|
334
|
+
Args:
|
|
335
|
+
min_: Minimum X value (can be datetime or numeric)
|
|
336
|
+
max_: Maximum X value (can be datetime or numeric)
|
|
337
|
+
|
|
338
|
+
Note: Values will be normalized to float internally.
|
|
339
|
+
"""
|
|
340
|
+
values = [v for v in [min_, max_] if v is not None]
|
|
341
|
+
if values:
|
|
342
|
+
self._x_display_metadata = DataMetadata.from_sequence(values)
|
|
343
|
+
|
|
344
|
+
min_float = self._in_fmt.convert(min_) if min_ is not None else None
|
|
345
|
+
max_float = self._in_fmt.convert(max_) if max_ is not None else None
|
|
346
|
+
|
|
347
|
+
self._x_min, self._x_max = self._set_limits(
|
|
348
|
+
self._x_min, self._x_max, min_float, max_float
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
def y_limits(self) -> tuple[float, float]:
|
|
352
|
+
"""Get the Y-axis limits as normalized floats."""
|
|
353
|
+
return self._limits(self._y_min, self._y_max, True)
|
|
354
|
+
|
|
355
|
+
def set_y_limits(
|
|
356
|
+
self, min_: DataValue | None = None, max_: DataValue | None = None
|
|
357
|
+
) -> None:
|
|
358
|
+
"""Set min and max Y values for displaying.
|
|
359
|
+
|
|
360
|
+
Args:
|
|
361
|
+
min_: Minimum Y value (can be datetime or numeric)
|
|
362
|
+
max_: Maximum Y value (can be datetime or numeric)
|
|
363
|
+
|
|
364
|
+
Note: Values will be normalized to float internally.
|
|
365
|
+
"""
|
|
366
|
+
values = [v for v in [min_, max_] if v is not None]
|
|
367
|
+
if values:
|
|
368
|
+
self._y_display_metadata = DataMetadata.from_sequence(values)
|
|
369
|
+
|
|
370
|
+
min_float = self._in_fmt.convert(min_) if min_ is not None else None
|
|
371
|
+
max_float = self._in_fmt.convert(max_) if max_ is not None else None
|
|
372
|
+
|
|
373
|
+
self._y_min, self._y_max = self._set_limits(
|
|
374
|
+
self._y_min, self._y_max, min_float, max_float
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
def _set_limits(
|
|
378
|
+
self,
|
|
379
|
+
init_min: float | None,
|
|
380
|
+
init_max: float | None,
|
|
381
|
+
min_: float | None = None,
|
|
382
|
+
max_: float | None = None,
|
|
383
|
+
) -> tuple[float | None, float | None]:
|
|
384
|
+
"""Set limits for an axis.
|
|
385
|
+
|
|
386
|
+
All parameters are already normalized to float.
|
|
387
|
+
|
|
388
|
+
Args:
|
|
389
|
+
init_min: Current minimum value
|
|
390
|
+
init_max: Current maximum value
|
|
391
|
+
min_: New minimum value (if setting)
|
|
392
|
+
max_: New maximum value (if setting)
|
|
393
|
+
|
|
394
|
+
Returns:
|
|
395
|
+
(min, max) tuple of floats or Nones
|
|
396
|
+
"""
|
|
397
|
+
values = list(filter(lambda v: v is not None, [init_min, init_max, min_, max_]))
|
|
398
|
+
if not values:
|
|
399
|
+
return None, None
|
|
400
|
+
|
|
401
|
+
if min_ is not None and max_ is not None:
|
|
402
|
+
if min_ >= max_:
|
|
403
|
+
raise ValueError("min_ is larger or equal than max_.")
|
|
404
|
+
init_min = min_
|
|
405
|
+
init_max = max_
|
|
406
|
+
elif min_ is not None:
|
|
407
|
+
if init_max is not None and min_ >= init_max:
|
|
408
|
+
raise ValueError("Previous max is smaller or equal to new min_.")
|
|
409
|
+
init_min = min_
|
|
410
|
+
elif max_ is not None:
|
|
411
|
+
if init_min is not None and init_min >= max_:
|
|
412
|
+
raise ValueError("Previous min is larger or equal to new max_.")
|
|
413
|
+
init_max = max_
|
|
414
|
+
else:
|
|
415
|
+
init_min = None
|
|
416
|
+
init_max = None
|
|
417
|
+
|
|
418
|
+
return init_min, init_max
|
|
419
|
+
|
|
420
|
+
def _limits(
|
|
421
|
+
self, low_set: float | None, high_set: float | None, is_height: bool
|
|
422
|
+
) -> tuple[float, float]:
|
|
423
|
+
"""Calculate the limits for an axis.
|
|
424
|
+
|
|
425
|
+
Aggregates metadata from all plots and works with normalized float values.
|
|
426
|
+
|
|
427
|
+
Args:
|
|
428
|
+
low_set: User-specified minimum value (already converted to float)
|
|
429
|
+
high_set: User-specified maximum value (already converted to float)
|
|
430
|
+
is_height: True for Y-axis, False for X-axis
|
|
431
|
+
|
|
432
|
+
Returns:
|
|
433
|
+
(min, max) as floats
|
|
434
|
+
"""
|
|
435
|
+
# Aggregate and store metadata for this axis
|
|
436
|
+
metadata = self._aggregate_metadata(is_height)
|
|
437
|
+
if metadata is not None:
|
|
438
|
+
if is_height:
|
|
439
|
+
self._y_display_metadata = metadata
|
|
440
|
+
else:
|
|
441
|
+
self._x_display_metadata = metadata
|
|
442
|
+
|
|
443
|
+
if low_set is not None and high_set is not None:
|
|
444
|
+
return low_set, high_set
|
|
445
|
+
|
|
446
|
+
# Get limits from normalized data (all floats)
|
|
447
|
+
low, high = None, None
|
|
448
|
+
for p in self._plots + self._texts:
|
|
449
|
+
if is_height:
|
|
450
|
+
_min, _max = _limit(p.height_vals())
|
|
451
|
+
else:
|
|
452
|
+
_min, _max = _limit(p.width_vals())
|
|
453
|
+
if low is None or high is None:
|
|
454
|
+
low = _min
|
|
455
|
+
high = _max
|
|
456
|
+
else:
|
|
457
|
+
low = min(_min, low)
|
|
458
|
+
high = max(_max, high)
|
|
459
|
+
|
|
460
|
+
# Calculate final limits
|
|
461
|
+
result = _choose(low, high, low_set, high_set)
|
|
462
|
+
return result
|
|
463
|
+
|
|
464
|
+
def _y_axis(self, ymin: float, ymax: float, label: str = "Y") -> list[str]:
|
|
465
|
+
"""Generate Y-axis labels.
|
|
466
|
+
|
|
467
|
+
Uses stored metadata to convert float values back to display format
|
|
468
|
+
(datetime or numeric).
|
|
469
|
+
|
|
470
|
+
Args:
|
|
471
|
+
ymin: Minimum Y value (as normalized float/timestamp)
|
|
472
|
+
ymax: Maximum Y value (as normalized float/timestamp)
|
|
473
|
+
label: Axis label
|
|
474
|
+
|
|
475
|
+
Returns:
|
|
476
|
+
List of formatted axis labels
|
|
477
|
+
"""
|
|
478
|
+
if self._y_display_metadata is None:
|
|
479
|
+
self._y_display_metadata = DataMetadata(is_datetime=False, timezone=None)
|
|
480
|
+
|
|
481
|
+
delta = abs(ymax - ymin)
|
|
482
|
+
y_delta = delta / self.height
|
|
483
|
+
|
|
484
|
+
# Convert delta for display formatting
|
|
485
|
+
delta_display = (
|
|
486
|
+
timedelta(seconds=delta) if self._y_display_metadata.is_datetime else delta
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
res = []
|
|
490
|
+
for i in range(self.height):
|
|
491
|
+
value_float = i * y_delta + ymin
|
|
492
|
+
|
|
493
|
+
# Convert to display type using metadata
|
|
494
|
+
value_display = self._y_display_metadata.convert_for_display(
|
|
495
|
+
value_float, self._y_display_timezone_override
|
|
496
|
+
)
|
|
497
|
+
|
|
498
|
+
if self.y_ticks_fkt:
|
|
499
|
+
value_display = self.y_ticks_fkt(value_display, value_display) # type: ignore[assignment]
|
|
500
|
+
|
|
501
|
+
res += [self._in_fmt.fmt(value_display, delta_display, chars=10) + " | "]
|
|
502
|
+
|
|
503
|
+
# add max separately
|
|
504
|
+
value_float = self.height * y_delta + ymin
|
|
505
|
+
value_display = self._y_display_metadata.convert_for_display(
|
|
506
|
+
value_float, self._y_display_timezone_override
|
|
507
|
+
)
|
|
508
|
+
|
|
509
|
+
if self.y_ticks_fkt:
|
|
510
|
+
value_display = self.y_ticks_fkt(value_display, value_display) # type: ignore[assignment]
|
|
511
|
+
|
|
512
|
+
res += [self._in_fmt.fmt(value_display, delta_display, chars=10) + " |"]
|
|
513
|
+
|
|
514
|
+
ylbl = f"({label})"
|
|
515
|
+
ylbl_left = (10 - len(ylbl)) // 2
|
|
516
|
+
ylbl_right = ylbl_left + len(ylbl) % 2
|
|
517
|
+
|
|
518
|
+
res += [" " * (ylbl_left) + ylbl + " " * (ylbl_right) + " ^"]
|
|
519
|
+
return list(reversed(res))
|
|
520
|
+
|
|
521
|
+
def _x_axis(
|
|
522
|
+
self, xmin: float, xmax: float, label: str = "X", with_y_axis: bool = False
|
|
523
|
+
) -> list[str]:
|
|
524
|
+
"""Generate X-axis labels.
|
|
525
|
+
|
|
526
|
+
Uses stored metadata to convert float values back to display format
|
|
527
|
+
(datetime or numeric).
|
|
528
|
+
|
|
529
|
+
Args:
|
|
530
|
+
xmin: Minimum X value (as normalized float/timestamp)
|
|
531
|
+
xmax: Maximum X value (as normalized float/timestamp)
|
|
532
|
+
label: Axis label
|
|
533
|
+
with_y_axis: Whether to add spacing for Y-axis labels
|
|
534
|
+
|
|
535
|
+
Returns:
|
|
536
|
+
List of formatted axis labels
|
|
537
|
+
"""
|
|
538
|
+
meta = self._x_display_metadata
|
|
539
|
+
if meta is None:
|
|
540
|
+
meta = DataMetadata(is_datetime=False, timezone=None)
|
|
541
|
+
|
|
542
|
+
delta = abs(xmax - xmin)
|
|
543
|
+
x_delta = delta / self.width
|
|
544
|
+
|
|
545
|
+
# Convert delta for display formatting
|
|
546
|
+
delta_display = timedelta(seconds=delta) if meta.is_datetime else delta
|
|
547
|
+
|
|
548
|
+
starts = ["", ""]
|
|
549
|
+
if with_y_axis:
|
|
550
|
+
starts = ["-" * 11 + "|-", " " * 11 + "| "]
|
|
551
|
+
res = []
|
|
552
|
+
|
|
553
|
+
res += [
|
|
554
|
+
starts[0]
|
|
555
|
+
+ "|---------" * (self.width // 10)
|
|
556
|
+
+ "|"
|
|
557
|
+
+ "-" * (self.width % 10)
|
|
558
|
+
+ "-> ("
|
|
559
|
+
+ label
|
|
560
|
+
+ ")"
|
|
561
|
+
]
|
|
562
|
+
bottom = []
|
|
563
|
+
|
|
564
|
+
for i in range(self.width // 10 + 1):
|
|
565
|
+
value_float = i * 10 * x_delta + xmin
|
|
566
|
+
|
|
567
|
+
# Convert to display type using metadata
|
|
568
|
+
value_display = meta.convert_for_display(
|
|
569
|
+
value_float, self._x_display_timezone_override
|
|
570
|
+
)
|
|
571
|
+
|
|
572
|
+
if self.x_ticks_fkt:
|
|
573
|
+
value_display = self.x_ticks_fkt(value_display, value_display) # type: ignore[assignment]
|
|
574
|
+
|
|
575
|
+
bottom += [
|
|
576
|
+
self._in_fmt.fmt(value_display, delta_display, left=True, chars=9)
|
|
577
|
+
]
|
|
578
|
+
|
|
579
|
+
res += [starts[1] + " ".join(bottom)]
|
|
580
|
+
return res
|
|
581
|
+
|
|
582
|
+
def clear(self) -> None:
|
|
583
|
+
"""Remove all plots, texts and spans from the figure."""
|
|
584
|
+
self._plots = []
|
|
585
|
+
self._texts = []
|
|
586
|
+
self._spans = []
|
|
587
|
+
self._heats = []
|
|
588
|
+
|
|
589
|
+
def plot(
|
|
590
|
+
self,
|
|
591
|
+
X: DataValues,
|
|
592
|
+
Y: DataValues,
|
|
593
|
+
lc: ColorDefinition = None,
|
|
594
|
+
interp: Literal["linear"] | None = "linear",
|
|
595
|
+
label: str | None = None,
|
|
596
|
+
marker: str | None = None,
|
|
597
|
+
) -> None:
|
|
598
|
+
"""Create plot with X, Y values.
|
|
599
|
+
|
|
600
|
+
X and Y can contain either numeric values (int, float) or datetime values,
|
|
601
|
+
but not both in the same array. Data is normalized to float internally
|
|
602
|
+
for efficient processing.
|
|
603
|
+
|
|
604
|
+
Parameters:
|
|
605
|
+
X: DataValues
|
|
606
|
+
X values. Can be numeric or datetime, but must be consistent.
|
|
607
|
+
Y: DataValues
|
|
608
|
+
Y values. X and Y must have the same number of entries.
|
|
609
|
+
lc: ColorDefinition
|
|
610
|
+
The line color.
|
|
611
|
+
interp: str
|
|
612
|
+
The interpolation method. (None or 'linear').
|
|
613
|
+
label: str
|
|
614
|
+
The label for the legend.
|
|
615
|
+
marker: str
|
|
616
|
+
Instead of braille dots set a marker char.
|
|
617
|
+
"""
|
|
618
|
+
if len(X) > 0:
|
|
619
|
+
if lc is None:
|
|
620
|
+
lc = next(self._color_seq)[self.color_mode]
|
|
621
|
+
self._plots += [Plot(X, Y, lc, interp, label, marker, self._in_fmt)]
|
|
622
|
+
|
|
623
|
+
def scatter(
|
|
624
|
+
self,
|
|
625
|
+
X: DataValues,
|
|
626
|
+
Y: DataValues,
|
|
627
|
+
lc: ColorDefinition = None,
|
|
628
|
+
label: str | None = None,
|
|
629
|
+
marker: str | None = None,
|
|
630
|
+
) -> None:
|
|
631
|
+
"""Create a scatter plot with X, Y values.
|
|
632
|
+
|
|
633
|
+
X and Y can contain either numeric values (int, float) or datetime values,
|
|
634
|
+
but not both in the same array. Data is normalized to float internally
|
|
635
|
+
for efficient processing.
|
|
636
|
+
|
|
637
|
+
Parameters:
|
|
638
|
+
X: DataValues
|
|
639
|
+
X values. Can be numeric or datetime, but must be consistent.
|
|
640
|
+
Y: DataValues
|
|
641
|
+
Y values. X and Y must have the same number of entries.
|
|
642
|
+
lc: ColorDefinition
|
|
643
|
+
The line color.
|
|
644
|
+
label: str
|
|
645
|
+
The label for the legend.
|
|
646
|
+
marker: str
|
|
647
|
+
Instead of braille dots set a marker char.
|
|
648
|
+
"""
|
|
649
|
+
if len(X) > 0:
|
|
650
|
+
if lc is None:
|
|
651
|
+
lc = next(self._color_seq)[self.color_mode]
|
|
652
|
+
self._plots += [Plot(X, Y, lc, None, label, marker, self._in_fmt)]
|
|
653
|
+
|
|
654
|
+
def histogram(
|
|
655
|
+
self, X: DataValues, bins: int = 160, lc: ColorDefinition = None
|
|
656
|
+
) -> None:
|
|
657
|
+
"""Compute and plot the histogram over X.
|
|
658
|
+
|
|
659
|
+
X can contain either numeric values (e.g. int, float) or datetime values.
|
|
660
|
+
Data is normalized to float internally for efficient processing.
|
|
661
|
+
|
|
662
|
+
Parameters:
|
|
663
|
+
X: DataValues
|
|
664
|
+
X values. Can be numeric or datetime.
|
|
665
|
+
bins: int
|
|
666
|
+
The number of bins to put X entries in (columns).
|
|
667
|
+
lc: ColorDefinition
|
|
668
|
+
The line color.
|
|
669
|
+
"""
|
|
670
|
+
if len(X) > 0:
|
|
671
|
+
if lc is None:
|
|
672
|
+
lc = next(self._color_seq)[self.color_mode]
|
|
673
|
+
self._plots += [Histogram(X, bins, lc)]
|
|
674
|
+
|
|
675
|
+
def text(
|
|
676
|
+
self,
|
|
677
|
+
X: DataValues,
|
|
678
|
+
Y: DataValues,
|
|
679
|
+
texts: Sequence[str],
|
|
680
|
+
lc: ColorDefinition = None,
|
|
681
|
+
) -> None:
|
|
682
|
+
"""Plot texts at coordinates X, Y.
|
|
683
|
+
|
|
684
|
+
Always print the first character of a text at its
|
|
685
|
+
x, y coordinate and continue to the right. Character
|
|
686
|
+
extending the canvas are cut.
|
|
687
|
+
|
|
688
|
+
X and Y can contain either numeric values (int, float) or datetime values,
|
|
689
|
+
but not both in the same array. Data is normalized to float internally
|
|
690
|
+
for efficient processing.
|
|
691
|
+
|
|
692
|
+
Parameters:
|
|
693
|
+
X: DataValues
|
|
694
|
+
X values. Can be numeric or datetime, but must be consistent.
|
|
695
|
+
Y: DataValues
|
|
696
|
+
Y values.
|
|
697
|
+
texts: Sequence[str]
|
|
698
|
+
Texts to print. X, Y and texts must have the same number of entries.
|
|
699
|
+
lc: ColorDefinition
|
|
700
|
+
The (text) line color.
|
|
701
|
+
"""
|
|
702
|
+
if len(X) > 0:
|
|
703
|
+
self._texts += [Text(X, Y, texts, lc, self._in_fmt)]
|
|
704
|
+
|
|
705
|
+
def axvline(
|
|
706
|
+
self, x: float, ymin: float = 0, ymax: float = 1, lc: ColorDefinition = None
|
|
707
|
+
) -> None:
|
|
708
|
+
"""Plot a vertical line at x.
|
|
709
|
+
|
|
710
|
+
Parameters:
|
|
711
|
+
x: float x-coordinate of the vertical line.
|
|
712
|
+
In the range [0, 1]
|
|
713
|
+
ymin: float Minimum y-coordinate of the vertical line.
|
|
714
|
+
In the range [0, 1]
|
|
715
|
+
ymax: float Maximum y-coordinate of the vertical line.
|
|
716
|
+
In the range [0, 1]
|
|
717
|
+
lc: ColorDefinition The line color.
|
|
718
|
+
"""
|
|
719
|
+
self._spans.append(Span(x, x, ymin, ymax, lc))
|
|
720
|
+
|
|
721
|
+
def axvspan(
|
|
722
|
+
self,
|
|
723
|
+
xmin: float,
|
|
724
|
+
xmax: float,
|
|
725
|
+
ymin: float = 0,
|
|
726
|
+
ymax: float = 1,
|
|
727
|
+
lc: ColorDefinition = None,
|
|
728
|
+
) -> None:
|
|
729
|
+
"""Plot a vertical rectangle from (xmin,ymin) to (xmax, ymax).
|
|
730
|
+
|
|
731
|
+
Parameters:
|
|
732
|
+
xmin: float Minimum x-coordinate of the rectangle.
|
|
733
|
+
In the range [0, 1]
|
|
734
|
+
xmax: float Maximum x-coordinate of the rectangle.
|
|
735
|
+
In the range [0, 1]
|
|
736
|
+
ymin: float Minimum y-coordinate of the rectangle.
|
|
737
|
+
In the range [0, 1]
|
|
738
|
+
ymax: float Maximum y-coordinate of the rectangle.
|
|
739
|
+
In the range [0, 1]
|
|
740
|
+
lc: ColorDefinition The line color.
|
|
741
|
+
"""
|
|
742
|
+
self._spans.append(Span(xmin, xmax, ymin, ymax, lc))
|
|
743
|
+
|
|
744
|
+
def axhline(
|
|
745
|
+
self, y: float, xmin: float = 0, xmax: float = 1, lc: ColorDefinition = None
|
|
746
|
+
) -> None:
|
|
747
|
+
"""Plot a horizontal line at y.
|
|
748
|
+
|
|
749
|
+
Parameters:
|
|
750
|
+
y: float y-coordinate of the horizontal line.
|
|
751
|
+
In the range [0, 1]
|
|
752
|
+
x_min: float Minimum x-coordinate of the vertical line.
|
|
753
|
+
In the range [0, 1]
|
|
754
|
+
x_max: float Maximum x-coordinate of the vertical line.
|
|
755
|
+
In the range [0, 1]
|
|
756
|
+
lc: ColorDefinition The line color.
|
|
757
|
+
"""
|
|
758
|
+
self._spans.append(Span(xmin, xmax, y, y, lc))
|
|
759
|
+
|
|
760
|
+
def axhspan(
|
|
761
|
+
self,
|
|
762
|
+
ymin: float,
|
|
763
|
+
ymax: float,
|
|
764
|
+
xmin: float = 0,
|
|
765
|
+
xmax: float = 1,
|
|
766
|
+
lc: ColorDefinition = None,
|
|
767
|
+
) -> None:
|
|
768
|
+
"""Plot a horizontal rectangle from (xmin,ymin) to (xmax, ymax).
|
|
769
|
+
|
|
770
|
+
Parameters:
|
|
771
|
+
ymin: float Minimum y-coordinate of the rectangle.
|
|
772
|
+
In the range [0, 1]
|
|
773
|
+
ymax: float Maximum y-coordinate of the rectangle.
|
|
774
|
+
In the range [0, 1]
|
|
775
|
+
xmin: float Minimum x-coordinate of the rectangle.
|
|
776
|
+
In the range [0, 1]
|
|
777
|
+
xmax: float Maximum x-coordinate of the rectangle.
|
|
778
|
+
In the range [0, 1]
|
|
779
|
+
lc: ColorDefinition The line color.
|
|
780
|
+
"""
|
|
781
|
+
self._spans.append(Span(xmin, xmax, ymin, ymax, lc))
|
|
782
|
+
|
|
783
|
+
def imgshow(self, X: HeatInput, cmap: str | Colormap | None = None) -> None:
|
|
784
|
+
"""Display data as an image, i.e., on a 2D regular raster.
|
|
785
|
+
|
|
786
|
+
Parameters:
|
|
787
|
+
X: array-like
|
|
788
|
+
The image data. Supported array shapes are:
|
|
789
|
+
- (M, N): an image with scalar data. The values are mapped
|
|
790
|
+
to colors using a colormap. The values have to be in
|
|
791
|
+
the 0-1 (float) range. Out of range, invalid type and
|
|
792
|
+
None values are handled by the cmap.
|
|
793
|
+
- (M, N, 3): an image with RGB values (0-1 float or 0-255 int).
|
|
794
|
+
|
|
795
|
+
The first two dimensions (M, N) define the rows and columns of the
|
|
796
|
+
image.
|
|
797
|
+
|
|
798
|
+
cmap: cmapstr or Colormap
|
|
799
|
+
The Colormap instance or registered colormap name used
|
|
800
|
+
to map scalar data to colors. This parameter is ignored
|
|
801
|
+
for RGB data.
|
|
802
|
+
"""
|
|
803
|
+
if len(X) > 0:
|
|
804
|
+
self._heats += [Heat(X, cmap)]
|
|
805
|
+
|
|
806
|
+
def show(self, legend: bool = False) -> str:
|
|
807
|
+
"""Compute the plot.
|
|
808
|
+
|
|
809
|
+
Parameters:
|
|
810
|
+
legend: bool Add the legend? default: False
|
|
811
|
+
|
|
812
|
+
Returns:
|
|
813
|
+
plot: str
|
|
814
|
+
"""
|
|
815
|
+
xmin, xmax = self.x_limits()
|
|
816
|
+
ymin, ymax = self.y_limits()
|
|
817
|
+
if self._plots and all(isinstance(p, Histogram) for p in self._plots):
|
|
818
|
+
ymin = 0.0
|
|
819
|
+
|
|
820
|
+
if self._heats and self._width is None and self._height is None:
|
|
821
|
+
self.height = len(self._heats[0].X)
|
|
822
|
+
self.width = len(self._heats[0].X[0])
|
|
823
|
+
|
|
824
|
+
# create canvas
|
|
825
|
+
canvas = Canvas(
|
|
826
|
+
self.width,
|
|
827
|
+
self.height,
|
|
828
|
+
xmin,
|
|
829
|
+
ymin,
|
|
830
|
+
xmax,
|
|
831
|
+
ymax,
|
|
832
|
+
self.background,
|
|
833
|
+
**self._color_kwargs,
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
for s in self._spans:
|
|
837
|
+
s.write(canvas, self.with_colors)
|
|
838
|
+
|
|
839
|
+
plot_origin = False
|
|
840
|
+
for p in self._plots:
|
|
841
|
+
p.write(canvas, self.with_colors, self._in_fmt)
|
|
842
|
+
if isinstance(p, Plot):
|
|
843
|
+
plot_origin = True
|
|
844
|
+
|
|
845
|
+
for t in self._texts:
|
|
846
|
+
t.write(canvas, self.with_colors, self._in_fmt)
|
|
847
|
+
|
|
848
|
+
for h in self._heats:
|
|
849
|
+
h.write(canvas)
|
|
850
|
+
|
|
851
|
+
if self.origin and plot_origin:
|
|
852
|
+
# print X / Y origin axis
|
|
853
|
+
canvas.line(xmin, 0.0, xmax, 0.0)
|
|
854
|
+
canvas.line(0.0, ymin, 0.0, ymax)
|
|
855
|
+
|
|
856
|
+
res = canvas.plot(linesep=self.linesep)
|
|
857
|
+
|
|
858
|
+
# add y axis
|
|
859
|
+
yaxis = self._y_axis(ymin, ymax, label=self.y_label)
|
|
860
|
+
res = (
|
|
861
|
+
yaxis[0]
|
|
862
|
+
+ self.linesep # up arrow
|
|
863
|
+
+ yaxis[1]
|
|
864
|
+
+ self.linesep # maximum
|
|
865
|
+
+ self.linesep.join(
|
|
866
|
+
lbl + line
|
|
867
|
+
for lbl, line in zip(yaxis[2:], res.split(self.linesep), strict=True)
|
|
868
|
+
)
|
|
869
|
+
)
|
|
870
|
+
|
|
871
|
+
# add x axis
|
|
872
|
+
xaxis = self._x_axis(xmin, xmax, label=self.x_label, with_y_axis=True)
|
|
873
|
+
res = (
|
|
874
|
+
res
|
|
875
|
+
+ self.linesep # plot
|
|
876
|
+
+ self.linesep.join(xaxis)
|
|
877
|
+
)
|
|
878
|
+
|
|
879
|
+
if legend:
|
|
880
|
+
res += f"{self.linesep}{self.linesep}Legend:{self.linesep}-------{self.linesep}"
|
|
881
|
+
lines = []
|
|
882
|
+
for i, p in enumerate(self._plots):
|
|
883
|
+
if isinstance(p, Plot):
|
|
884
|
+
lbl = p.label or f"Label {i}"
|
|
885
|
+
marker = p.marker or ""
|
|
886
|
+
lines += [
|
|
887
|
+
color(
|
|
888
|
+
f"⠤{marker}⠤ {lbl}",
|
|
889
|
+
fg=p.lc,
|
|
890
|
+
mode=self.color_mode,
|
|
891
|
+
no_color=not self.with_colors,
|
|
892
|
+
)
|
|
893
|
+
]
|
|
894
|
+
res += self.linesep.join(lines)
|
|
895
|
+
return res
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _limit(values: Sequence[float]) -> tuple[float, float]:
|
|
899
|
+
"""Find min and max of normalized float values.
|
|
900
|
+
|
|
901
|
+
Args:
|
|
902
|
+
values: Sequence of already-normalized float values
|
|
903
|
+
|
|
904
|
+
Returns:
|
|
905
|
+
(min, max) as floats
|
|
906
|
+
"""
|
|
907
|
+
min_: float = 0.0
|
|
908
|
+
max_: float = 1.0
|
|
909
|
+
if len(values) > 0:
|
|
910
|
+
min_ = min(values)
|
|
911
|
+
max_ = max(values)
|
|
912
|
+
|
|
913
|
+
return min_, max_
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def _diff(low: float, high: float) -> float:
|
|
917
|
+
# assert type(low) is type(high)
|
|
918
|
+
if low == high:
|
|
919
|
+
if low == 0:
|
|
920
|
+
return 0.5
|
|
921
|
+
else:
|
|
922
|
+
return abs(low * 0.1)
|
|
923
|
+
else:
|
|
924
|
+
delta = abs(high - low)
|
|
925
|
+
return delta * 0.1
|
|
926
|
+
|
|
927
|
+
|
|
928
|
+
def _default(low_set: float | None, high_set: float | None) -> tuple[float, float]:
|
|
929
|
+
if low_set is None and high_set is None:
|
|
930
|
+
return 0.0, 1.0 # defaults
|
|
931
|
+
|
|
932
|
+
if low_set is None and high_set is not None:
|
|
933
|
+
if high_set <= 0:
|
|
934
|
+
return high_set - 1, high_set
|
|
935
|
+
else:
|
|
936
|
+
return 0.0, high_set
|
|
937
|
+
|
|
938
|
+
if low_set is not None and high_set is None:
|
|
939
|
+
if low_set >= 1:
|
|
940
|
+
return low_set, low_set + 1
|
|
941
|
+
else:
|
|
942
|
+
return low_set, 1.0
|
|
943
|
+
|
|
944
|
+
# Should never get here! => checked in function before
|
|
945
|
+
raise ValueError("Unexpected inputs!")
|
|
946
|
+
|
|
947
|
+
|
|
948
|
+
def _choose(
|
|
949
|
+
low: float | None, high: float | None, low_set: float | None, high_set: float | None
|
|
950
|
+
) -> tuple[float, float]:
|
|
951
|
+
if low is None or high is None:
|
|
952
|
+
# either all are set or none
|
|
953
|
+
assert low is None
|
|
954
|
+
assert high is None
|
|
955
|
+
return _default(low_set, high_set)
|
|
956
|
+
|
|
957
|
+
else: # some data
|
|
958
|
+
if low_set is None and high_set is None:
|
|
959
|
+
# no restrictions from user, use low & high
|
|
960
|
+
diff = _diff(low, high)
|
|
961
|
+
return low - diff, high + diff
|
|
962
|
+
|
|
963
|
+
if low_set is None and high_set is not None:
|
|
964
|
+
# user sets high end
|
|
965
|
+
if high_set < low:
|
|
966
|
+
# high is smaller than lowest value
|
|
967
|
+
return high_set - 1, high_set
|
|
968
|
+
|
|
969
|
+
diff = _diff(low, high_set)
|
|
970
|
+
return low - diff, high_set
|
|
971
|
+
|
|
972
|
+
if low_set is not None and high_set is None:
|
|
973
|
+
# user sets low end
|
|
974
|
+
if low_set > high:
|
|
975
|
+
# low is larger than highest value
|
|
976
|
+
return low_set, low_set + 1
|
|
977
|
+
|
|
978
|
+
diff = _diff(low_set, high)
|
|
979
|
+
return low_set, high + diff
|
|
980
|
+
|
|
981
|
+
# Should never get here! => checked in function before
|
|
982
|
+
raise ValueError("Unexpected inputs!")
|