leanplot 1.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.
- leanplot/__init__.py +14 -0
- leanplot/core/__init__.py +38 -0
- leanplot/core/bar.py +64 -0
- leanplot/core/colors/__init__.py +11 -0
- leanplot/core/colors/categorical.py +72 -0
- leanplot/core/double_y.py +219 -0
- leanplot/core/functions.py +145 -0
- leanplot/core/line.py +135 -0
- leanplot/core/mixin.py +187 -0
- leanplot/tests/__init__.py +8 -0
- leanplot/tests/test_categorical.py +64 -0
- leanplot/tests/test_line.py +192 -0
- leanplot/tests/test_mixin.py +208 -0
- leanplot-1.0.0.dist-info/METADATA +68 -0
- leanplot-1.0.0.dist-info/RECORD +18 -0
- leanplot-1.0.0.dist-info/WHEEL +5 -0
- leanplot-1.0.0.dist-info/licenses/LICENSE.txt +16 -0
- leanplot-1.0.0.dist-info/top_level.txt +1 -0
leanplot/__init__.py
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Joel Kimmich
|
|
3
|
+
# ------------------------------------------------------------------------------
|
|
4
|
+
# License
|
|
5
|
+
# This file is part of LeanPlot, distributed under MIT license.
|
|
6
|
+
# ------------------------------------------------------------------------------
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
__all__ = []
|
|
10
|
+
|
|
11
|
+
from leanplot.core import *
|
|
12
|
+
from leanplot.core import __all__ as _core_all_
|
|
13
|
+
|
|
14
|
+
__all__.extend(_core_all_)
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Joel Kimmich
|
|
3
|
+
# ------------------------------------------------------------------------------
|
|
4
|
+
# License
|
|
5
|
+
# This file is part of LeanPlot, distributed under MIT license.
|
|
6
|
+
# ------------------------------------------------------------------------------
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
from leanplot.core.bar import BarPlot, TimeBarPlot
|
|
10
|
+
from leanplot.core.double_y import DoubleY
|
|
11
|
+
from leanplot.core.functions import (
|
|
12
|
+
cm_to_inch,
|
|
13
|
+
custom_numeric_ticks_formatter,
|
|
14
|
+
format_date_x_axis,
|
|
15
|
+
is_numeric,
|
|
16
|
+
round_valid,
|
|
17
|
+
)
|
|
18
|
+
from leanplot.core.line import LinePlot, TimePlot
|
|
19
|
+
from leanplot.core.mixin import PlotMixin
|
|
20
|
+
|
|
21
|
+
__all__ = ["BarPlot", "TimeBarPlot"]
|
|
22
|
+
__all__.extend(["DoubleY"])
|
|
23
|
+
__all__.extend(
|
|
24
|
+
[
|
|
25
|
+
"is_numeric",
|
|
26
|
+
"cm_to_inch",
|
|
27
|
+
"custom_numeric_ticks_formatter",
|
|
28
|
+
"format_date_x_axis",
|
|
29
|
+
"round_valid",
|
|
30
|
+
]
|
|
31
|
+
)
|
|
32
|
+
__all__.extend(["LinePlot", "TimePlot"])
|
|
33
|
+
__all__.extend(["PlotMixin"])
|
|
34
|
+
|
|
35
|
+
from .colors import *
|
|
36
|
+
from .colors import __all__ as _colors_all_
|
|
37
|
+
|
|
38
|
+
__all__.extend(_colors_all_)
|
leanplot/core/bar.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Joel Kimmich
|
|
3
|
+
# ------------------------------------------------------------------------------
|
|
4
|
+
# License
|
|
5
|
+
# This file is part of LeanPlot, distributed under MIT license.
|
|
6
|
+
# ------------------------------------------------------------------------------
|
|
7
|
+
#
|
|
8
|
+
from datetime import timedelta
|
|
9
|
+
|
|
10
|
+
import matplotlib.dates as mdates
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
from leanplot.core.functions import format_date_x_axis, is_numeric
|
|
14
|
+
from leanplot.core.mixin import PlotMixin
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class BarPlot(PlotMixin):
|
|
18
|
+
def __init__(self, bar_values, bar_positions=None, y_title="Heat / kWh", **kwargs):
|
|
19
|
+
assert len(bar_values) > 1
|
|
20
|
+
if bar_positions is None:
|
|
21
|
+
bar_positions = range(len(bar_values))
|
|
22
|
+
super().__init__(x=bar_positions, ys=bar_values, y_title=y_title, **kwargs)
|
|
23
|
+
|
|
24
|
+
@property
|
|
25
|
+
def bar_width(self):
|
|
26
|
+
if len(self.x) > 1:
|
|
27
|
+
diff = np.diff(self.x)
|
|
28
|
+
min_diff = np.min(diff)
|
|
29
|
+
return min_diff * 0.8
|
|
30
|
+
return 1
|
|
31
|
+
|
|
32
|
+
def plot(self):
|
|
33
|
+
self.ax.bar(self.x, self.ys, width=self.bar_width, color=next(self.next_color))
|
|
34
|
+
self.format()
|
|
35
|
+
|
|
36
|
+
def format(self):
|
|
37
|
+
super().format()
|
|
38
|
+
if is_numeric(self.x[0]):
|
|
39
|
+
self.ax.set_xlim(left=self.x[0] - self.bar_width / 0.8 / 2, right=self.x[-1] + self.bar_width / 0.8 / 2)
|
|
40
|
+
self.format_numeric_ticks()
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class TimeBarPlot(BarPlot):
|
|
44
|
+
def __init__(self, bar_values, bar_positions, y_title="Heat / kWh", **kwargs):
|
|
45
|
+
super().__init__(bar_values, bar_positions, y_title=y_title, **kwargs)
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def bar_width(self) -> float:
|
|
49
|
+
if len(self.x) > 1:
|
|
50
|
+
date_nums = mdates.date2num(self.x)
|
|
51
|
+
time_diffs = np.diff(date_nums)
|
|
52
|
+
min_diff = time_diffs[0]
|
|
53
|
+
return float(min_diff * 0.8)
|
|
54
|
+
else:
|
|
55
|
+
return 1 / 24
|
|
56
|
+
|
|
57
|
+
def format(self):
|
|
58
|
+
super().format()
|
|
59
|
+
format_date_x_axis(self.x[0], self.x[-1], self.ax, return_version=False)
|
|
60
|
+
self.ax.set_xlim(
|
|
61
|
+
left=self.x[0] - timedelta(days=self.bar_width / 0.8 / 2),
|
|
62
|
+
right=self.x[-1] + timedelta(days=self.bar_width / 0.8 / 2),
|
|
63
|
+
)
|
|
64
|
+
self.format_numeric_ticks()
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Joel Kimmich
|
|
3
|
+
# ------------------------------------------------------------------------------
|
|
4
|
+
# License
|
|
5
|
+
# This file is part of LeanPlot, distributed under MIT license.
|
|
6
|
+
# ------------------------------------------------------------------------------
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
from leanplot.core.colors.categorical import ColorPalette
|
|
10
|
+
|
|
11
|
+
__all__ = ["ColorPalette"]
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Joel Kimmich
|
|
3
|
+
# ------------------------------------------------------------------------------
|
|
4
|
+
# License
|
|
5
|
+
# This file is part of LeanPlot, distributed under MIT license.
|
|
6
|
+
# ------------------------------------------------------------------------------
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
import cmcrameri.cm as cmc
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ColorPalette:
|
|
13
|
+
"""
|
|
14
|
+
Provide a categorical color palette from a pre-defined categorical colormap.
|
|
15
|
+
|
|
16
|
+
Parameters
|
|
17
|
+
----------
|
|
18
|
+
colormap_name : str, default="batlow"
|
|
19
|
+
Base name of the colormap defined in `cmcrameri.cm`, without the categorical suffix
|
|
20
|
+
(for example "batlow", "grayC", "oslo"). The categorical variant is accessed by appending "S".
|
|
21
|
+
number_of_colors : int, default=5
|
|
22
|
+
Number of categorical colors to use from the colormap.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
def __init__(self, colormap_name: str = "oslo", number_of_colors: int = 5) -> None:
|
|
26
|
+
self.colormap_name = colormap_name
|
|
27
|
+
self.number_of_colors = number_of_colors
|
|
28
|
+
|
|
29
|
+
@property
|
|
30
|
+
def colors(self) -> list[tuple[float, float, float]]:
|
|
31
|
+
"""
|
|
32
|
+
Return categorical colors from the predefined categorical colormap.
|
|
33
|
+
|
|
34
|
+
Returns
|
|
35
|
+
-------
|
|
36
|
+
list[tuple[float, float, float]]
|
|
37
|
+
List of RGB color tuples with values in the range [0, 1].
|
|
38
|
+
|
|
39
|
+
Raises
|
|
40
|
+
------
|
|
41
|
+
AttributeError
|
|
42
|
+
If the categorical colormap `f"{colormap_name}S"` is not defined in `cmcrameri.cm`.
|
|
43
|
+
ValueError
|
|
44
|
+
If `number_of_colors` exceeds the number of colors available in the categorical colormap.
|
|
45
|
+
"""
|
|
46
|
+
categorical_colormap = getattr(cmc, f"{self.colormap_name}S")
|
|
47
|
+
available_colors = categorical_colormap.colors
|
|
48
|
+
if self.number_of_colors > len(available_colors):
|
|
49
|
+
raise ValueError(
|
|
50
|
+
f"Requested {self.number_of_colors} colors, but colormap "
|
|
51
|
+
f"'{self.colormap_name}S' only provides {len(available_colors)} colors."
|
|
52
|
+
)
|
|
53
|
+
return [tuple(color[:3]) for color in available_colors[: self.number_of_colors]]
|
|
54
|
+
|
|
55
|
+
@staticmethod
|
|
56
|
+
def available_categorical_colormap_names() -> list[str]:
|
|
57
|
+
"""
|
|
58
|
+
Return the base names of all colormaps in `cmcrameri.cm` that provide a categorical variant.
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
list[str]
|
|
63
|
+
Sorted list of base colormap names (without the categorical "S" suffix and without
|
|
64
|
+
reversed "_r" variants) for which `f"{name}S"` exists in `cmcrameri.cm`.
|
|
65
|
+
"""
|
|
66
|
+
categorical_suffix = "S"
|
|
67
|
+
reversed_suffix = "_r"
|
|
68
|
+
base_names = []
|
|
69
|
+
for attribute_name in dir(cmc):
|
|
70
|
+
if attribute_name.endswith(categorical_suffix) and not attribute_name.endswith(reversed_suffix):
|
|
71
|
+
base_names.append(attribute_name[: -len(categorical_suffix)])
|
|
72
|
+
return sorted(base_names)
|
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Joel Kimmich
|
|
3
|
+
# ------------------------------------------------------------------------------
|
|
4
|
+
# License
|
|
5
|
+
# This file is part of LeanPlot, distributed under MIT license.
|
|
6
|
+
# ------------------------------------------------------------------------------
|
|
7
|
+
#
|
|
8
|
+
|
|
9
|
+
# ------------------------------------------------------------------------------
|
|
10
|
+
# Copyright (c) 2026 Joel Kimmich
|
|
11
|
+
# ------------------------------------------------------------------------------
|
|
12
|
+
# License
|
|
13
|
+
# This file is part of LeanPlot, distributed under MIT license.
|
|
14
|
+
# ------------------------------------------------------------------------------
|
|
15
|
+
#
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
from leanplot.core.mixin import PlotMixin
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class DoubleY(PlotMixin):
|
|
22
|
+
def __init__(
|
|
23
|
+
self,
|
|
24
|
+
x,
|
|
25
|
+
ys_left: list,
|
|
26
|
+
ys_right: list,
|
|
27
|
+
labels_left=None,
|
|
28
|
+
labels_right=None,
|
|
29
|
+
y_title_left="Heat Flux / W",
|
|
30
|
+
y_title_right="Temperature / K",
|
|
31
|
+
marker_size=6,
|
|
32
|
+
**kwargs,
|
|
33
|
+
):
|
|
34
|
+
if not isinstance(ys_left, list):
|
|
35
|
+
ys_left = [ys_left]
|
|
36
|
+
if not isinstance(ys_right, list):
|
|
37
|
+
ys_right = [ys_right]
|
|
38
|
+
ys_left = [np.array(y) for y in ys_left]
|
|
39
|
+
ys_right = [np.array(y) for y in ys_right]
|
|
40
|
+
|
|
41
|
+
super().__init__(x=np.array(x), ys=ys_left, y_title=y_title_left, marker_size=marker_size, **kwargs)
|
|
42
|
+
|
|
43
|
+
self.ys_left = ys_left
|
|
44
|
+
self.ys_right = ys_right
|
|
45
|
+
self.y_title_right = y_title_right
|
|
46
|
+
|
|
47
|
+
self.ax_right = self.ax.twinx()
|
|
48
|
+
|
|
49
|
+
if labels_left is None:
|
|
50
|
+
labels_left = [None] * len(ys_left)
|
|
51
|
+
if labels_right is None:
|
|
52
|
+
labels_right = [None] * len(ys_right)
|
|
53
|
+
if not isinstance(labels_left, list):
|
|
54
|
+
labels_left = [labels_left]
|
|
55
|
+
if not isinstance(labels_right, list):
|
|
56
|
+
labels_right = [labels_right]
|
|
57
|
+
|
|
58
|
+
self.labels_left = labels_left
|
|
59
|
+
self.labels_right = labels_right
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def ax_left(self):
|
|
63
|
+
return self.ax
|
|
64
|
+
|
|
65
|
+
def scale_right_axis(self):
|
|
66
|
+
"""
|
|
67
|
+
Scales the right axis so that the major ticks matches the left one.
|
|
68
|
+
"""
|
|
69
|
+
left_ticks = self.ax.get_yticks()
|
|
70
|
+
num_ticks = len(left_ticks)
|
|
71
|
+
|
|
72
|
+
# Get right axis data range
|
|
73
|
+
right_data_min, right_data_max = self.ax_right.get_ylim()
|
|
74
|
+
right_range = right_data_max - right_data_min
|
|
75
|
+
|
|
76
|
+
# Generate nice spacings: [1,2,5] * 10^n
|
|
77
|
+
nice_spacings = []
|
|
78
|
+
for n in range(-10, 10):
|
|
79
|
+
for base in [1, 2, 3, 4, 5, 6]:
|
|
80
|
+
nice_spacings.append(base * 10**n)
|
|
81
|
+
|
|
82
|
+
# Find the minimum spacing that can cover the data range with num_ticks-1 intervals
|
|
83
|
+
target_spacing = right_range / (num_ticks - 1)
|
|
84
|
+
right_tick_spacing = min([s for s in nice_spacings if s >= target_spacing])
|
|
85
|
+
|
|
86
|
+
# Calculate new right axis limits based on nice spacing
|
|
87
|
+
right_min = np.floor(right_data_min / right_tick_spacing) * right_tick_spacing
|
|
88
|
+
right_max = right_min + (num_ticks - 1) * right_tick_spacing
|
|
89
|
+
|
|
90
|
+
# Create right axis ticks
|
|
91
|
+
right_ticks = np.linspace(right_min, right_max, num_ticks)
|
|
92
|
+
|
|
93
|
+
self.ax_right.set_ylim(right_min, right_max)
|
|
94
|
+
self.ax_right.set_yticks(right_ticks)
|
|
95
|
+
|
|
96
|
+
def plot(self):
|
|
97
|
+
|
|
98
|
+
for i, y in enumerate(self.ys_left):
|
|
99
|
+
self._add_line_and_label(
|
|
100
|
+
self.ax.plot(
|
|
101
|
+
self.x,
|
|
102
|
+
y,
|
|
103
|
+
label=self.labels_left[i],
|
|
104
|
+
color=next(self.next_color),
|
|
105
|
+
marker=next(self.next_marker),
|
|
106
|
+
markersize=self.marker_size,
|
|
107
|
+
linestyle="None",
|
|
108
|
+
),
|
|
109
|
+
self.labels_left[i],
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
for i, y in enumerate(self.ys_right):
|
|
113
|
+
self._add_line_and_label(
|
|
114
|
+
self.ax_right.plot(
|
|
115
|
+
self.x,
|
|
116
|
+
y,
|
|
117
|
+
label=self.labels_right[i],
|
|
118
|
+
color=next(self.next_color),
|
|
119
|
+
marker=next(self.next_marker),
|
|
120
|
+
markersize=self.marker_size,
|
|
121
|
+
linestyle="None",
|
|
122
|
+
),
|
|
123
|
+
self.labels_right[i],
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
self.format()
|
|
127
|
+
|
|
128
|
+
def format(self):
|
|
129
|
+
if self.x_title is not None:
|
|
130
|
+
self.ax.set_xlabel(self.x_title)
|
|
131
|
+
if self.y_title is not None:
|
|
132
|
+
self.ax.set_ylabel(self.y_title)
|
|
133
|
+
if self.y_title_right is not None:
|
|
134
|
+
self.ax_right.set_ylabel(self.y_title_right)
|
|
135
|
+
|
|
136
|
+
self.ax.grid(True)
|
|
137
|
+
|
|
138
|
+
if not isinstance(self.x[0], str):
|
|
139
|
+
dx = self.x[1] - self.x[0] if len(self.x) > 1 else 0
|
|
140
|
+
self.ax.set_xlim(left=self.x[0] - dx / 2, right=self.x[-1] + dx / 2)
|
|
141
|
+
|
|
142
|
+
if self.x_is_datetime:
|
|
143
|
+
self.formate_x_datetime(self.x[0] - dx / 2, self.x[-1] + dx / 2)
|
|
144
|
+
|
|
145
|
+
self.format_numeric_ticks()
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
class DoubleYSeparated(DoubleY):
|
|
149
|
+
def __init__(
|
|
150
|
+
self,
|
|
151
|
+
x,
|
|
152
|
+
ys_left: list,
|
|
153
|
+
ys_right: list,
|
|
154
|
+
labels=None,
|
|
155
|
+
y_title_left="Heat Flux / W",
|
|
156
|
+
y_title_right="Temperature / K",
|
|
157
|
+
marker_size=6,
|
|
158
|
+
same_marker=False,
|
|
159
|
+
):
|
|
160
|
+
super().__init__(
|
|
161
|
+
x, ys_left, ys_right, y_title_left=y_title_left, y_title_right=y_title_right, marker_size=marker_size
|
|
162
|
+
)
|
|
163
|
+
|
|
164
|
+
if labels is None:
|
|
165
|
+
labels = [None] * len(ys_left)
|
|
166
|
+
if not isinstance(labels, list):
|
|
167
|
+
labels = [labels]
|
|
168
|
+
self.labels = labels
|
|
169
|
+
self.same_marker = same_marker
|
|
170
|
+
|
|
171
|
+
def plot(self):
|
|
172
|
+
|
|
173
|
+
for i, (y_left, y_right) in enumerate(zip(self.ys_left, self.ys_right)):
|
|
174
|
+
marker = next(self.next_marker)
|
|
175
|
+
|
|
176
|
+
self._add_line(
|
|
177
|
+
self.ax.plot(
|
|
178
|
+
self.x,
|
|
179
|
+
y_left,
|
|
180
|
+
color="black",
|
|
181
|
+
marker=marker,
|
|
182
|
+
markersize=self.marker_size,
|
|
183
|
+
linestyle="None",
|
|
184
|
+
)
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
if not self.same_marker:
|
|
188
|
+
marker = next(self.next_marker)
|
|
189
|
+
|
|
190
|
+
self._add_line(
|
|
191
|
+
self.ax_right.plot(
|
|
192
|
+
self.x,
|
|
193
|
+
y_right,
|
|
194
|
+
color=self.colors[0],
|
|
195
|
+
marker=marker,
|
|
196
|
+
markersize=self.marker_size,
|
|
197
|
+
linestyle="None",
|
|
198
|
+
)
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
if self.labels[i] is not None and not self.same_marker:
|
|
202
|
+
self.ax.plot(
|
|
203
|
+
[],
|
|
204
|
+
[],
|
|
205
|
+
color="black",
|
|
206
|
+
marker=marker,
|
|
207
|
+
linestyle="None",
|
|
208
|
+
markersize=self.marker_size,
|
|
209
|
+
label=self.labels[i],
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
self.format()
|
|
213
|
+
|
|
214
|
+
def format(self):
|
|
215
|
+
super().format()
|
|
216
|
+
self.ax.yaxis.label.set_color("black")
|
|
217
|
+
self.ax.tick_params(axis="y", colors="black")
|
|
218
|
+
self.ax_right.yaxis.label.set_color(self.colors[0])
|
|
219
|
+
self.ax_right.tick_params(axis="y", colors=self.colors[0])
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Joel Kimmich
|
|
3
|
+
# ------------------------------------------------------------------------------
|
|
4
|
+
# License
|
|
5
|
+
# This file is part of LeanPlot, distributed under MIT license.
|
|
6
|
+
# ------------------------------------------------------------------------------
|
|
7
|
+
#
|
|
8
|
+
from datetime import datetime, timedelta
|
|
9
|
+
|
|
10
|
+
import matplotlib.dates as mdates
|
|
11
|
+
import numpy as np
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def is_numeric(value):
|
|
15
|
+
"""
|
|
16
|
+
Checks if value is a numeric value.
|
|
17
|
+
|
|
18
|
+
Parameters
|
|
19
|
+
----------
|
|
20
|
+
value : Any
|
|
21
|
+
Value to check.
|
|
22
|
+
|
|
23
|
+
Returns
|
|
24
|
+
-------
|
|
25
|
+
bool
|
|
26
|
+
True if value is numeric, False otherwise
|
|
27
|
+
"""
|
|
28
|
+
list_of_numerics = [float, int, np.number] # add numeric data types here if necessary
|
|
29
|
+
for numeric in list_of_numerics:
|
|
30
|
+
if isinstance(value, numeric):
|
|
31
|
+
return True
|
|
32
|
+
return False
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def cm_to_inch(value):
|
|
36
|
+
return value / 2.54
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def custom_numeric_ticks_formatter(x, pos):
|
|
40
|
+
"""
|
|
41
|
+
Format numbers/ticks to match ISO 80000.
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
x : float | int
|
|
46
|
+
The number to format.
|
|
47
|
+
pos
|
|
48
|
+
|
|
49
|
+
Returns
|
|
50
|
+
-------
|
|
51
|
+
str :
|
|
52
|
+
The formatted number with a short protected space as thousands separator and a "," as decimal.
|
|
53
|
+
"""
|
|
54
|
+
if abs(x) >= 1000:
|
|
55
|
+
s = f"{x:,.0f}".replace(",", "\u202f").replace(".", ",")
|
|
56
|
+
return s
|
|
57
|
+
else:
|
|
58
|
+
return f"{x:g}".replace(".", ",")
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def format_date_x_axis(start_date: datetime, end_date: datetime, ax, return_version: bool = True) -> str | None:
|
|
62
|
+
"""
|
|
63
|
+
Formats the ticks of an axis as date.
|
|
64
|
+
|
|
65
|
+
Depending on the date range it parses the time/date to days, weeks, months and years with matching minor ticks.
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
start_date : datetime
|
|
70
|
+
The start date of the axis.
|
|
71
|
+
end_date : datetime
|
|
72
|
+
The end date of the axis.
|
|
73
|
+
ax : matplotlib.axis.Axis
|
|
74
|
+
The axis to format.
|
|
75
|
+
return_version : bool
|
|
76
|
+
Whether to return the version of the axis or not.
|
|
77
|
+
|
|
78
|
+
Returns
|
|
79
|
+
-------
|
|
80
|
+
str | None :
|
|
81
|
+
The formatted Axis if return_version is True, otherwise None.
|
|
82
|
+
"""
|
|
83
|
+
time_delta = end_date - start_date
|
|
84
|
+
if time_delta <= timedelta(days=3):
|
|
85
|
+
version = "day"
|
|
86
|
+
ax.xaxis.set_major_locator(mdates.HourLocator(byhour=(0, 6, 12, 18)))
|
|
87
|
+
ax.xaxis.set_minor_locator(mdates.HourLocator())
|
|
88
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m. %-H Uhr"))
|
|
89
|
+
elif time_delta <= timedelta(days=10):
|
|
90
|
+
version = "week"
|
|
91
|
+
ax.xaxis.set_major_locator(mdates.DayLocator())
|
|
92
|
+
ax.xaxis.set_minor_locator(mdates.HourLocator(byhour=(0, 6, 12, 18)))
|
|
93
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m."))
|
|
94
|
+
elif time_delta <= timedelta(days=80):
|
|
95
|
+
version = "month"
|
|
96
|
+
ax.xaxis.set_major_locator(mdates.DayLocator(bymonthday=(1, 7, 15, 23)))
|
|
97
|
+
ax.xaxis.set_minor_locator(mdates.DayLocator())
|
|
98
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%d.%m."))
|
|
99
|
+
else:
|
|
100
|
+
version = "year"
|
|
101
|
+
ax.xaxis.set_major_locator(mdates.MonthLocator())
|
|
102
|
+
ax.xaxis.set_minor_locator(mdates.DayLocator(bymonthday=(1, 7, 14, 21, 28)))
|
|
103
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter("%-m."))
|
|
104
|
+
ax.grid(True)
|
|
105
|
+
ax.tick_params(axis="x", which="minor", bottom=True)
|
|
106
|
+
ax.set_xlim(left=start_date)
|
|
107
|
+
ax.set_xlim(right=end_date)
|
|
108
|
+
if return_version:
|
|
109
|
+
return version
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def round_valid(number: float | int, valid_digits: int, return_str: bool = False):
|
|
113
|
+
if np.isnan(number):
|
|
114
|
+
if return_str:
|
|
115
|
+
return "NaN"
|
|
116
|
+
return np.nan
|
|
117
|
+
if number == np.inf:
|
|
118
|
+
if return_str:
|
|
119
|
+
return "Infinity"
|
|
120
|
+
return np.inf
|
|
121
|
+
if number == -np.inf:
|
|
122
|
+
if return_str:
|
|
123
|
+
return "-Infinity"
|
|
124
|
+
return -np.inf
|
|
125
|
+
if int(number) == 0:
|
|
126
|
+
# number < 1
|
|
127
|
+
if number == 0:
|
|
128
|
+
if return_str:
|
|
129
|
+
return "0"
|
|
130
|
+
return 0
|
|
131
|
+
counter = 0
|
|
132
|
+
while int(number) == 0:
|
|
133
|
+
number = number * 10
|
|
134
|
+
counter += 1
|
|
135
|
+
if return_str:
|
|
136
|
+
format_str = f"{{:{counter + 1 + valid_digits}.{valid_digits + counter - 1}f}}"
|
|
137
|
+
return format_str.format(np.round(number, valid_digits) / 10**counter)
|
|
138
|
+
return np.round(np.round(number, valid_digits) / 10**counter, counter + valid_digits - 1)
|
|
139
|
+
else:
|
|
140
|
+
ten_digits = int(np.log10(abs(number))) + 1
|
|
141
|
+
if ten_digits >= valid_digits:
|
|
142
|
+
number = int(number)
|
|
143
|
+
if return_str:
|
|
144
|
+
return f"{np.round(number, valid_digits - ten_digits)}"
|
|
145
|
+
return np.round(number, valid_digits - ten_digits)
|
leanplot/core/line.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
# ------------------------------------------------------------------------------
|
|
2
|
+
# Copyright (c) 2026 Joel Kimmich
|
|
3
|
+
# ------------------------------------------------------------------------------
|
|
4
|
+
# License
|
|
5
|
+
# This file is part of LeanPlot, distributed under MIT license.
|
|
6
|
+
# ------------------------------------------------------------------------------
|
|
7
|
+
#
|
|
8
|
+
import numpy as np
|
|
9
|
+
|
|
10
|
+
from leanplot.core.mixin import PlotMixin
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class LinePlot(PlotMixin):
|
|
14
|
+
def __init__(
|
|
15
|
+
self,
|
|
16
|
+
x,
|
|
17
|
+
ys: list | np.ndarray,
|
|
18
|
+
labels=None,
|
|
19
|
+
y_scale=1,
|
|
20
|
+
y_title="Values",
|
|
21
|
+
linewidth=1.8,
|
|
22
|
+
x_title=None,
|
|
23
|
+
width_mm=160,
|
|
24
|
+
height_mm=90,
|
|
25
|
+
):
|
|
26
|
+
if not isinstance(ys, list):
|
|
27
|
+
if not (isinstance(ys, np.ndarray) and len(ys.shape) > 1 and ys.shape[0] > 1 and ys.shape[1] > 1):
|
|
28
|
+
ys = [np.array(ys)]
|
|
29
|
+
else:
|
|
30
|
+
if len(ys) == len(x):
|
|
31
|
+
ys = [np.array(ys)]
|
|
32
|
+
ys = [np.array(y) for y in ys]
|
|
33
|
+
super().__init__(x=np.array(x), ys=ys, y_title=y_title, x_title=x_title, width_mm=width_mm, height_mm=height_mm)
|
|
34
|
+
|
|
35
|
+
if labels is None:
|
|
36
|
+
labels = [None] * len(ys)
|
|
37
|
+
if not isinstance(labels, list):
|
|
38
|
+
labels = [labels]
|
|
39
|
+
self.labels = labels
|
|
40
|
+
|
|
41
|
+
self.y_scale = y_scale
|
|
42
|
+
self.line_width = linewidth
|
|
43
|
+
|
|
44
|
+
def plot(self, x=None, ys=None, labels=None):
|
|
45
|
+
if x is None:
|
|
46
|
+
x = self.x
|
|
47
|
+
if ys is None:
|
|
48
|
+
ys = self.ys
|
|
49
|
+
if labels is None:
|
|
50
|
+
labels = self.labels
|
|
51
|
+
if not isinstance(labels, list):
|
|
52
|
+
labels = [labels]
|
|
53
|
+
for i, y in enumerate(ys):
|
|
54
|
+
self._add_line(
|
|
55
|
+
self.ax.plot(
|
|
56
|
+
x,
|
|
57
|
+
np.array(y) * self.y_scale,
|
|
58
|
+
label=labels[i],
|
|
59
|
+
color=next(self.next_color),
|
|
60
|
+
linewidth=self.line_width,
|
|
61
|
+
linestyle=next(self.next_line_style),
|
|
62
|
+
)
|
|
63
|
+
)
|
|
64
|
+
self.format()
|
|
65
|
+
|
|
66
|
+
def add_points(self, x, ys, labels=None):
|
|
67
|
+
"""
|
|
68
|
+
Add points to the plot (e.g. measurement data to interpolated lines)
|
|
69
|
+
|
|
70
|
+
The ys values are not scaled with self.y_scale!
|
|
71
|
+
|
|
72
|
+
Parameters
|
|
73
|
+
----------
|
|
74
|
+
x : float | int | np.ndarray | list
|
|
75
|
+
x values
|
|
76
|
+
ys : float | int | np.ndarray | list
|
|
77
|
+
y values. If two-dimensional several point plots are added with different colors.
|
|
78
|
+
labels : list | str, optional
|
|
79
|
+
The labels of the point groups, accordingly
|
|
80
|
+
"""
|
|
81
|
+
if not isinstance(x, np.ndarray):
|
|
82
|
+
x = np.array(x)
|
|
83
|
+
if not isinstance(ys, np.ndarray):
|
|
84
|
+
ys = np.array(ys)
|
|
85
|
+
ys = np.atleast_2d(ys)
|
|
86
|
+
if labels is None:
|
|
87
|
+
labels = [None] * len(ys)
|
|
88
|
+
if not isinstance(labels, list):
|
|
89
|
+
labels = [labels]
|
|
90
|
+
assert len(labels) == len(ys)
|
|
91
|
+
|
|
92
|
+
for i, y in enumerate(ys):
|
|
93
|
+
self._add_line_and_label(
|
|
94
|
+
self.ax.plot(
|
|
95
|
+
x,
|
|
96
|
+
np.array(y),
|
|
97
|
+
label=labels[i],
|
|
98
|
+
color=next(self.next_color),
|
|
99
|
+
linestyle="none",
|
|
100
|
+
marker=next(self.next_marker),
|
|
101
|
+
),
|
|
102
|
+
labels[i],
|
|
103
|
+
)
|
|
104
|
+
self.format()
|
|
105
|
+
|
|
106
|
+
def plot_marker(self):
|
|
107
|
+
for i, y in enumerate(self.ys):
|
|
108
|
+
self.ax.plot(
|
|
109
|
+
self.x,
|
|
110
|
+
np.array(y) * self.y_scale,
|
|
111
|
+
label=self.labels[i],
|
|
112
|
+
color=next(self.next_color),
|
|
113
|
+
marker=next(self.next_marker),
|
|
114
|
+
)
|
|
115
|
+
self.format()
|
|
116
|
+
|
|
117
|
+
def plot_stack(self):
|
|
118
|
+
self.ax.stackplot(
|
|
119
|
+
self.x,
|
|
120
|
+
*[y * self.y_scale for y in self.ys],
|
|
121
|
+
labels=self.labels,
|
|
122
|
+
colors=self.colors,
|
|
123
|
+
)
|
|
124
|
+
self.format()
|
|
125
|
+
|
|
126
|
+
def format(self):
|
|
127
|
+
super().format()
|
|
128
|
+
self.format_numeric_ticks()
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
class TimePlot(LinePlot):
|
|
132
|
+
def format(self):
|
|
133
|
+
super().format()
|
|
134
|
+
self.formate_x_datetime()
|
|
135
|
+
self.format_numeric_ticks()
|