leanplot 1.0.0__tar.gz
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-1.0.0/LICENSE.txt +16 -0
- leanplot-1.0.0/PKG-INFO +68 -0
- leanplot-1.0.0/README.md +26 -0
- leanplot-1.0.0/leanplot/__init__.py +14 -0
- leanplot-1.0.0/leanplot/core/__init__.py +38 -0
- leanplot-1.0.0/leanplot/core/bar.py +64 -0
- leanplot-1.0.0/leanplot/core/colors/__init__.py +11 -0
- leanplot-1.0.0/leanplot/core/colors/categorical.py +72 -0
- leanplot-1.0.0/leanplot/core/double_y.py +219 -0
- leanplot-1.0.0/leanplot/core/functions.py +145 -0
- leanplot-1.0.0/leanplot/core/line.py +135 -0
- leanplot-1.0.0/leanplot/core/mixin.py +187 -0
- leanplot-1.0.0/leanplot/tests/__init__.py +8 -0
- leanplot-1.0.0/leanplot/tests/test_categorical.py +64 -0
- leanplot-1.0.0/leanplot/tests/test_line.py +192 -0
- leanplot-1.0.0/leanplot/tests/test_mixin.py +208 -0
- leanplot-1.0.0/leanplot.egg-info/PKG-INFO +68 -0
- leanplot-1.0.0/leanplot.egg-info/SOURCES.txt +21 -0
- leanplot-1.0.0/leanplot.egg-info/dependency_links.txt +1 -0
- leanplot-1.0.0/leanplot.egg-info/requires.txt +10 -0
- leanplot-1.0.0/leanplot.egg-info/top_level.txt +1 -0
- leanplot-1.0.0/pyproject.toml +79 -0
- leanplot-1.0.0/setup.cfg +4 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Joel Kimmich
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated
|
|
6
|
+
documentation files (the "Software"), to deal in the Software without restriction, including without limitation the
|
|
7
|
+
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit
|
|
8
|
+
persons to whom the Software is furnished to do so, subject to the following conditions:
|
|
9
|
+
|
|
10
|
+
The above copyright notice and this permission notice shall be included in all copies or substantial portions of the
|
|
11
|
+
Software.
|
|
12
|
+
|
|
13
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE
|
|
14
|
+
WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
|
|
15
|
+
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR
|
|
16
|
+
OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
|
leanplot-1.0.0/PKG-INFO
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: leanplot
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: Provides some classes for fast matplotlib plotting based on ISO 80000.
|
|
5
|
+
Author-email: Joel Kimmich <software@igte.uni-stuttgart.de>
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://codeberg.org/IGTE/leanplot
|
|
8
|
+
Project-URL: Documentation, https://codeberg.org/IGTE/leanplot
|
|
9
|
+
Project-URL: Repository, https://codeberg.org/IGTE/leanplot
|
|
10
|
+
Project-URL: Issues, https://codeberg.org/IGTE/leanplot/issues
|
|
11
|
+
Project-URL: Changelog, https://codeberg.org/IGTE/leanplot/CHANGELOG.rst
|
|
12
|
+
Classifier: Development Status :: 4 - Beta
|
|
13
|
+
Classifier: Intended Audience :: Science/Research
|
|
14
|
+
Classifier: Intended Audience :: Education
|
|
15
|
+
Classifier: Topic :: Documentation
|
|
16
|
+
Classifier: Topic :: Scientific/Engineering :: Visualization
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Classifier: Programming Language :: Python :: 3
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
20
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
21
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
22
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
23
|
+
Classifier: Programming Language :: Python :: 3 :: Only
|
|
24
|
+
Classifier: Typing :: Typed
|
|
25
|
+
Classifier: Operating System :: Microsoft :: Windows
|
|
26
|
+
Classifier: Operating System :: POSIX
|
|
27
|
+
Classifier: Operating System :: Unix
|
|
28
|
+
Classifier: Operating System :: MacOS
|
|
29
|
+
Requires-Python: >=3.11
|
|
30
|
+
Description-Content-Type: text/markdown
|
|
31
|
+
License-File: LICENSE.txt
|
|
32
|
+
Requires-Dist: matplotlib>=3.10.0
|
|
33
|
+
Requires-Dist: numpy>=2.0.0
|
|
34
|
+
Requires-Dist: cmcrameri
|
|
35
|
+
Provides-Extra: dev
|
|
36
|
+
Requires-Dist: ruff>=0.15.8; extra == "dev"
|
|
37
|
+
Requires-Dist: build>=1.4.2; extra == "dev"
|
|
38
|
+
Requires-Dist: pytest; extra == "dev"
|
|
39
|
+
Requires-Dist: pytest-cov; extra == "dev"
|
|
40
|
+
Requires-Dist: twine; extra == "dev"
|
|
41
|
+
Dynamic: license-file
|
|
42
|
+
|
|
43
|
+
[//]: # (<h1 align="center">)
|
|
44
|
+
|
|
45
|
+
[//]: # (<img src="somePath" alt="LeanPlot Logo" height="65">)
|
|
46
|
+
|
|
47
|
+
[//]: # (</h1><br>)
|
|
48
|
+
|
|
49
|
+
# LeanPlot
|
|
50
|
+
|
|
51
|
+
[](https://pypi.org/project/leanplot/)
|
|
52
|
+
[](https://pypi.org/project/leanplot/)
|
|
53
|
+
[](https://pypi.org/project/leanplot/)
|
|
54
|
+
|
|
55
|
+
LeanPlot is a baseline plotting tool for formatted matplotlib plots based on ISO 80000 using "," as decimal and a
|
|
56
|
+
short protected space as thousands separator. Also, it makes it easy to plot over time with correct formatting.
|
|
57
|
+
|
|
58
|
+
The result can always be manipulated like any other matplotlib axes and figures.
|
|
59
|
+
|
|
60
|
+
## Installation
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
pip install leanplot
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
## License
|
|
67
|
+
|
|
68
|
+
See LICENSE.txt for the license.
|
leanplot-1.0.0/README.md
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
[//]: # (<h1 align="center">)
|
|
2
|
+
|
|
3
|
+
[//]: # (<img src="somePath" alt="LeanPlot Logo" height="65">)
|
|
4
|
+
|
|
5
|
+
[//]: # (</h1><br>)
|
|
6
|
+
|
|
7
|
+
# LeanPlot
|
|
8
|
+
|
|
9
|
+
[](https://pypi.org/project/leanplot/)
|
|
10
|
+
[](https://pypi.org/project/leanplot/)
|
|
11
|
+
[](https://pypi.org/project/leanplot/)
|
|
12
|
+
|
|
13
|
+
LeanPlot is a baseline plotting tool for formatted matplotlib plots based on ISO 80000 using "," as decimal and a
|
|
14
|
+
short protected space as thousands separator. Also, it makes it easy to plot over time with correct formatting.
|
|
15
|
+
|
|
16
|
+
The result can always be manipulated like any other matplotlib axes and figures.
|
|
17
|
+
|
|
18
|
+
## Installation
|
|
19
|
+
|
|
20
|
+
```bash
|
|
21
|
+
pip install leanplot
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
## License
|
|
25
|
+
|
|
26
|
+
See LICENSE.txt for the license.
|
|
@@ -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_)
|
|
@@ -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)
|