tesorotools-python 0.1.0__py3-none-any.whl → 0.1.2__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.
- tesorotools/__init__.py +2 -0
- tesorotools/artists/__init__.py +21 -4
- tesorotools/artists/_common.py +133 -6
- tesorotools/artists/bar_line.py +18 -1
- tesorotools/artists/barh_plot.py +46 -16
- tesorotools/artists/box_plot.py +16 -1
- tesorotools/artists/line_plot.py +55 -2
- tesorotools/artists/matrix.py +19 -9
- tesorotools/artists/plotly_backend.py +8 -3
- tesorotools/artists/sharing.py +59 -0
- tesorotools/artists/shock_plot.py +13 -1
- tesorotools/artists/stacked.py +47 -16
- tesorotools/artists/type_curve.py +21 -9
- tesorotools/artists/vector_plot.py +20 -9
- tesorotools/artists/waterfall.py +13 -1
- {tesorotools_python-0.1.0.dist-info → tesorotools_python-0.1.2.dist-info}/METADATA +1 -1
- {tesorotools_python-0.1.0.dist-info → tesorotools_python-0.1.2.dist-info}/RECORD +19 -18
- {tesorotools_python-0.1.0.dist-info → tesorotools_python-0.1.2.dist-info}/WHEEL +0 -0
- {tesorotools_python-0.1.0.dist-info → tesorotools_python-0.1.2.dist-info}/entry_points.txt +0 -0
tesorotools/__init__.py
CHANGED
|
@@ -72,6 +72,7 @@ from tesorotools.artists import (
|
|
|
72
72
|
TypeCurve,
|
|
73
73
|
VectorPlot,
|
|
74
74
|
Waterfall,
|
|
75
|
+
share_y_axes,
|
|
75
76
|
)
|
|
76
77
|
from tesorotools.orchestration import CompositeRegistry, iter_contexts
|
|
77
78
|
from tesorotools.providers.base import (
|
|
@@ -177,6 +178,7 @@ __all__ = [
|
|
|
177
178
|
"register_providers",
|
|
178
179
|
"register_tag",
|
|
179
180
|
"register_tags",
|
|
181
|
+
"share_y_axes",
|
|
180
182
|
]
|
|
181
183
|
|
|
182
184
|
|
tesorotools/artists/__init__.py
CHANGED
|
@@ -12,9 +12,16 @@ Each class follows the same shape:
|
|
|
12
12
|
``data_path`` (``.feather`` on disk), never both.
|
|
13
13
|
* ``from_yaml(loader, node)`` -- builds the same instance
|
|
14
14
|
from a ``!{name}`` YAML tag.
|
|
15
|
-
* ``
|
|
16
|
-
|
|
17
|
-
|
|
15
|
+
* ``build()`` -- renders the chart in memory and returns
|
|
16
|
+
``(Figure, Axes)``; ``save(fig, path=..., dpi=...)``
|
|
17
|
+
writes it; ``plot()`` does both and closes the figure.
|
|
18
|
+
Every artist exposes the three (see :class:`Artist`).
|
|
19
|
+
* ``to_plotly()`` -- the same chart as an interactive
|
|
20
|
+
Plotly figure (all but ``IntradayPlot``);
|
|
21
|
+
:func:`plotly_unsupported` lists the keys it drops.
|
|
22
|
+
|
|
23
|
+
:func:`share_y_axes` gives the charts that declare the same
|
|
24
|
+
``y_group`` one common y scale.
|
|
18
25
|
|
|
19
26
|
Shared layout, annotation and styling helpers (plus the
|
|
20
27
|
``Format`` and ``Legend`` config holders) live in
|
|
@@ -25,13 +32,20 @@ live under :mod:`tesorotools.render`; this package is
|
|
|
25
32
|
strictly image output.
|
|
26
33
|
"""
|
|
27
34
|
|
|
28
|
-
from tesorotools.artists._common import
|
|
35
|
+
from tesorotools.artists._common import (
|
|
36
|
+
Artist,
|
|
37
|
+
FigureLifecycle,
|
|
38
|
+
Format,
|
|
39
|
+
Legend,
|
|
40
|
+
plotly_unsupported,
|
|
41
|
+
)
|
|
29
42
|
from tesorotools.artists.bar_line import BarLinePlot
|
|
30
43
|
from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
|
|
31
44
|
from tesorotools.artists.box_plot import BoxPlot
|
|
32
45
|
from tesorotools.artists.intraday_plot import IntradayPlot
|
|
33
46
|
from tesorotools.artists.line_plot import LinePlot
|
|
34
47
|
from tesorotools.artists.matrix import MatrixChart
|
|
48
|
+
from tesorotools.artists.sharing import share_y_axes
|
|
35
49
|
from tesorotools.artists.shock_plot import ShockChart
|
|
36
50
|
from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
|
|
37
51
|
from tesorotools.artists.type_curve import TypeCurve
|
|
@@ -42,6 +56,7 @@ __all__ = [
|
|
|
42
56
|
"Artist",
|
|
43
57
|
"BarLinePlot",
|
|
44
58
|
"BoxPlot",
|
|
59
|
+
"FigureLifecycle",
|
|
45
60
|
"Format",
|
|
46
61
|
"GroupedBarChart",
|
|
47
62
|
"HorizontalBarChart",
|
|
@@ -55,4 +70,6 @@ __all__ = [
|
|
|
55
70
|
"TypeCurve",
|
|
56
71
|
"VectorPlot",
|
|
57
72
|
"Waterfall",
|
|
73
|
+
"plotly_unsupported",
|
|
74
|
+
"share_y_axes",
|
|
58
75
|
]
|
tesorotools/artists/_common.py
CHANGED
|
@@ -23,13 +23,18 @@ overrides only need to update ``plots.yaml``.
|
|
|
23
23
|
|
|
24
24
|
from __future__ import annotations
|
|
25
25
|
|
|
26
|
+
from collections.abc import Sequence
|
|
26
27
|
from pathlib import Path
|
|
27
28
|
from typing import Any, Literal, Protocol, Self, cast, runtime_checkable
|
|
28
29
|
|
|
29
30
|
import matplotlib.pyplot as plt
|
|
30
31
|
import pandas as pd
|
|
31
32
|
from matplotlib.axes import Axes
|
|
32
|
-
from matplotlib.dates import
|
|
33
|
+
from matplotlib.dates import (
|
|
34
|
+
AutoDateLocator,
|
|
35
|
+
ConciseDateFormatter,
|
|
36
|
+
YearLocator,
|
|
37
|
+
)
|
|
33
38
|
from matplotlib.dates import (
|
|
34
39
|
date2num, # type: ignore[reportUnknownVariableType]
|
|
35
40
|
)
|
|
@@ -126,6 +131,70 @@ def dynamic_dpi(fig: Figure) -> int:
|
|
|
126
131
|
return int(round(min(max_dpi, max(min_dpi, target_px / long_in))))
|
|
127
132
|
|
|
128
133
|
|
|
134
|
+
def plotly_unsupported(artist: object) -> frozenset[str]:
|
|
135
|
+
"""Configuration keys *artist* accepts but ``to_plotly()`` drops.
|
|
136
|
+
|
|
137
|
+
*artist* is an artist class or instance. The keys change the PNG
|
|
138
|
+
but not the interactive figure; nested ones are dotted
|
|
139
|
+
(``"legend.ncol"``, ``"fmt.signed"``). A UI built on top can tell
|
|
140
|
+
the user why moving such a control leaves the figure as it is.
|
|
141
|
+
Needs no ``plotly``: it reads the class attribute
|
|
142
|
+
``PLOTLY_UNSUPPORTED``, which the test suite checks against real
|
|
143
|
+
renders.
|
|
144
|
+
"""
|
|
145
|
+
cls = artist if isinstance(artist, type) else type(artist)
|
|
146
|
+
if not hasattr(cls, "to_plotly"):
|
|
147
|
+
raise TypeError(f"{cls.__name__} has no to_plotly()")
|
|
148
|
+
declared: frozenset[str] | None = getattr(cls, "PLOTLY_UNSUPPORTED", None)
|
|
149
|
+
if declared is None:
|
|
150
|
+
raise TypeError(f"{cls.__name__} does not declare PLOTLY_UNSUPPORTED")
|
|
151
|
+
return frozenset(declared)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
class FigureLifecycle:
|
|
155
|
+
"""``save()`` and ``plot()`` on top of a subclass's ``build()``.
|
|
156
|
+
|
|
157
|
+
Gives an artist the same three-step life cycle as the rest:
|
|
158
|
+
``build()`` returns the figure in memory, ``save()`` writes it
|
|
159
|
+
and ``plot()`` does both and closes the figure. A subclass only
|
|
160
|
+
implements ``build()``.
|
|
161
|
+
"""
|
|
162
|
+
|
|
163
|
+
out_path: Path
|
|
164
|
+
|
|
165
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
166
|
+
raise NotImplementedError
|
|
167
|
+
|
|
168
|
+
def save(
|
|
169
|
+
self,
|
|
170
|
+
fig: Figure,
|
|
171
|
+
*,
|
|
172
|
+
path: Path | None = None,
|
|
173
|
+
dpi: int | None = None,
|
|
174
|
+
) -> Path:
|
|
175
|
+
"""Persist *fig* as a PNG and return the path written.
|
|
176
|
+
|
|
177
|
+
Defaults to ``self.out_path``; pass ``path`` to redirect.
|
|
178
|
+
``dpi`` overrides the save resolution; when omitted it is
|
|
179
|
+
derived from the figure size via :func:`dynamic_dpi`.
|
|
180
|
+
"""
|
|
181
|
+
target: Path = path if path is not None else self.out_path
|
|
182
|
+
save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
|
|
183
|
+
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
184
|
+
target, dpi=save_dpi
|
|
185
|
+
)
|
|
186
|
+
return target
|
|
187
|
+
|
|
188
|
+
def plot(self) -> Axes:
|
|
189
|
+
"""Build the chart, persist it to ``self.out_path`` and close it."""
|
|
190
|
+
fig, ax = self.build()
|
|
191
|
+
try:
|
|
192
|
+
self.save(fig)
|
|
193
|
+
finally:
|
|
194
|
+
plt.close(fig)
|
|
195
|
+
return ax
|
|
196
|
+
|
|
197
|
+
|
|
129
198
|
# ----------------------------------------------------------------------
|
|
130
199
|
# Structural contract for image artists
|
|
131
200
|
# ----------------------------------------------------------------------
|
|
@@ -137,13 +206,21 @@ class Artist(Protocol):
|
|
|
137
206
|
|
|
138
207
|
The built-in artists and any third-party artist
|
|
139
208
|
registered via :func:`tesorotools.register_artist`
|
|
140
|
-
expose the same
|
|
209
|
+
expose the same surface:
|
|
141
210
|
|
|
142
211
|
* ``out_path: Path`` -- the ``.png`` destination.
|
|
143
212
|
* ``from_yaml(loader, node) -> Self`` -- the YAML
|
|
144
213
|
constructor bound to the ``!{name}`` tag.
|
|
145
|
-
* ``
|
|
146
|
-
|
|
214
|
+
* ``build() -> (Figure, Axes)`` -- render the chart in
|
|
215
|
+
memory, without touching the disk.
|
|
216
|
+
* ``save(fig, *, path=None, dpi=None) -> Path`` -- write
|
|
217
|
+
a built figure (to ``out_path`` unless ``path`` says
|
|
218
|
+
otherwise).
|
|
219
|
+
* ``plot() -> Axes`` -- ``build()`` + ``save()``, close
|
|
220
|
+
the figure and return the axes for inspection.
|
|
221
|
+
|
|
222
|
+
:class:`FigureLifecycle` provides ``save()`` and ``plot()``
|
|
223
|
+
for an artist that only implements ``build()``.
|
|
147
224
|
|
|
148
225
|
The Protocol is ``runtime_checkable`` so consumers can
|
|
149
226
|
use ``isinstance(obj, Artist)`` to validate a candidate
|
|
@@ -156,6 +233,16 @@ class Artist(Protocol):
|
|
|
156
233
|
@classmethod
|
|
157
234
|
def from_yaml(cls, loader: Any, node: MappingNode) -> Self: ...
|
|
158
235
|
|
|
236
|
+
def build(self) -> tuple[Figure, Axes]: ...
|
|
237
|
+
|
|
238
|
+
def save(
|
|
239
|
+
self,
|
|
240
|
+
fig: Figure,
|
|
241
|
+
*,
|
|
242
|
+
path: Path | None = None,
|
|
243
|
+
dpi: int | None = None,
|
|
244
|
+
) -> Path: ...
|
|
245
|
+
|
|
159
246
|
def plot(self) -> Axes: ...
|
|
160
247
|
|
|
161
248
|
|
|
@@ -580,6 +667,12 @@ def date_span_ticks(
|
|
|
580
667
|
10 % of the span is dropped so the two labels do not collide. Without
|
|
581
668
|
it the plain in-span candidates are returned (falling back to the two
|
|
582
669
|
endpoints when the locator offers nothing inside).
|
|
670
|
+
|
|
671
|
+
When the locator steps by several years, the pinned grid is anchored to
|
|
672
|
+
the **start year** instead of to multiples of the step: a 2-year step
|
|
673
|
+
over 2023-2027 gives 2023, 2025, 2027 rather than the pinned 2023 plus
|
|
674
|
+
the calendar 2024, 2026, whose uneven gaps (and missing odd years) read
|
|
675
|
+
as if the axis skipped a year.
|
|
583
676
|
"""
|
|
584
677
|
lo: float = float(cast(Any, date2num(data_min)))
|
|
585
678
|
hi: float = float(cast(Any, date2num(data_max)))
|
|
@@ -589,6 +682,18 @@ def date_span_ticks(
|
|
|
589
682
|
)
|
|
590
683
|
if not pin_start:
|
|
591
684
|
return [t for t in candidates if lo <= t <= hi] or [lo, hi]
|
|
685
|
+
chosen: Any = locator.get_locator( # type: ignore[reportUnknownMemberType]
|
|
686
|
+
data_min, data_max
|
|
687
|
+
)
|
|
688
|
+
if isinstance(chosen, YearLocator):
|
|
689
|
+
step = int(cast(Any, chosen.base).step)
|
|
690
|
+
if step > 1:
|
|
691
|
+
first_year: int = num2date(lo).year
|
|
692
|
+
last_year: int = num2date(hi).year
|
|
693
|
+
candidates = [
|
|
694
|
+
float(cast(Any, date2num(pd.Timestamp(year=y, month=1, day=1))))
|
|
695
|
+
for y in range(first_year, last_year + 1, step)
|
|
696
|
+
]
|
|
592
697
|
min_gap: float = (hi - lo) * 0.10
|
|
593
698
|
interior: list[float] = [
|
|
594
699
|
t for t in candidates if lo < t <= hi and t - lo >= min_gap
|
|
@@ -1195,6 +1300,26 @@ def style_spines(
|
|
|
1195
1300
|
)
|
|
1196
1301
|
|
|
1197
1302
|
|
|
1303
|
+
def check_y_limits(
|
|
1304
|
+
limits: Sequence[float] | None,
|
|
1305
|
+
) -> tuple[float, float] | None:
|
|
1306
|
+
"""Validate a ``y_limits`` pair and return it as ``(bottom, top)``.
|
|
1307
|
+
|
|
1308
|
+
YAML hands the pair over as a list, so any two-item sequence is
|
|
1309
|
+
accepted; ``None`` (no fixed range) passes through. Raises
|
|
1310
|
+
``ValueError`` unless there are exactly two numbers with
|
|
1311
|
+
``bottom < top``.
|
|
1312
|
+
"""
|
|
1313
|
+
if limits is None:
|
|
1314
|
+
return None
|
|
1315
|
+
if len(limits) != 2:
|
|
1316
|
+
raise ValueError(f"y_limits must be (bottom, top): {limits!r}")
|
|
1317
|
+
bottom, top = float(limits[0]), float(limits[1])
|
|
1318
|
+
if not bottom < top:
|
|
1319
|
+
raise ValueError(f"y_limits bottom must be below top: {limits!r}")
|
|
1320
|
+
return bottom, top
|
|
1321
|
+
|
|
1322
|
+
|
|
1198
1323
|
def style_baseline(
|
|
1199
1324
|
ax: Axes,
|
|
1200
1325
|
reference: float = 0,
|
|
@@ -1235,6 +1360,7 @@ def annotate_last_values(
|
|
|
1235
1360
|
fontsize: float | None = None,
|
|
1236
1361
|
right_pad_px: float = 10.0,
|
|
1237
1362
|
headroom_pad_pt: float = 0.0,
|
|
1363
|
+
grow_top: bool = True,
|
|
1238
1364
|
) -> None:
|
|
1239
1365
|
"""Label the last non-NaN value of each column on the right.
|
|
1240
1366
|
|
|
@@ -1261,7 +1387,8 @@ def annotate_last_values(
|
|
|
1261
1387
|
y-limit, that limit grows enough to keep this many points
|
|
1262
1388
|
free and the labels are re-packed at the new scale, so a
|
|
1263
1389
|
tall stack never spills into a legend above the axes (nor
|
|
1264
|
-
is clipped at the top).
|
|
1390
|
+
is clipped at the top). ``grow_top=False`` keeps the top
|
|
1391
|
+
y-limit as it is (a fixed or shared y range must not move).
|
|
1265
1392
|
"""
|
|
1266
1393
|
fig = ax.get_figure()
|
|
1267
1394
|
if fig is None:
|
|
@@ -1333,7 +1460,7 @@ def annotate_last_values(
|
|
|
1333
1460
|
pad_px = headroom_pad_pt * fig.dpi / 72.0
|
|
1334
1461
|
top_axes_px: float = trans.transform((0, ymax))[1] # type: ignore[reportUnknownArgumentType]
|
|
1335
1462
|
needed_px = placements[-1] + text_height / 2 + pad_px
|
|
1336
|
-
if needed_px > top_axes_px:
|
|
1463
|
+
if grow_top and needed_px > top_axes_px:
|
|
1337
1464
|
new_top: float = trans.inverted().transform( # type: ignore[reportUnknownArgumentType]
|
|
1338
1465
|
(0, needed_px)
|
|
1339
1466
|
)[1]
|
tesorotools/artists/bar_line.py
CHANGED
|
@@ -28,7 +28,7 @@ from __future__ import annotations
|
|
|
28
28
|
|
|
29
29
|
import locale
|
|
30
30
|
from pathlib import Path
|
|
31
|
-
from typing import Any, Self
|
|
31
|
+
from typing import Any, ClassVar, Self
|
|
32
32
|
|
|
33
33
|
import matplotlib.pyplot as plt
|
|
34
34
|
import numpy as np
|
|
@@ -89,6 +89,23 @@ class BarLinePlot:
|
|
|
89
89
|
(``None`` labels every slot).
|
|
90
90
|
"""
|
|
91
91
|
|
|
92
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
93
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
94
|
+
#: by ``tests/test_plotly_support.py``.
|
|
95
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
96
|
+
{
|
|
97
|
+
"bar_width",
|
|
98
|
+
"compact",
|
|
99
|
+
"figsize",
|
|
100
|
+
"fmt.decimals",
|
|
101
|
+
"font_pt",
|
|
102
|
+
"legend.ncol",
|
|
103
|
+
"max_xticks",
|
|
104
|
+
"plot_size",
|
|
105
|
+
"x_rotation",
|
|
106
|
+
}
|
|
107
|
+
)
|
|
108
|
+
|
|
92
109
|
def __init__(
|
|
93
110
|
self,
|
|
94
111
|
out_path: Path,
|
tesorotools/artists/barh_plot.py
CHANGED
|
@@ -18,7 +18,7 @@ from __future__ import annotations
|
|
|
18
18
|
|
|
19
19
|
from enum import Enum
|
|
20
20
|
from pathlib import Path
|
|
21
|
-
from typing import Any, Self
|
|
21
|
+
from typing import Any, ClassVar, Self
|
|
22
22
|
|
|
23
23
|
import matplotlib.patheffects as pe
|
|
24
24
|
import matplotlib.pyplot as plt
|
|
@@ -32,6 +32,7 @@ from matplotlib.ticker import FuncFormatter
|
|
|
32
32
|
from yaml.nodes import MappingNode
|
|
33
33
|
|
|
34
34
|
from tesorotools.artists._common import (
|
|
35
|
+
FigureLifecycle,
|
|
35
36
|
AX_CONFIG,
|
|
36
37
|
Format,
|
|
37
38
|
Legend,
|
|
@@ -39,7 +40,6 @@ from tesorotools.artists._common import (
|
|
|
39
40
|
compact_barh_figsize,
|
|
40
41
|
compact_figure_kwargs,
|
|
41
42
|
compact_font_pt,
|
|
42
|
-
dynamic_dpi,
|
|
43
43
|
legend_layout,
|
|
44
44
|
place_legend,
|
|
45
45
|
resolve_data,
|
|
@@ -447,7 +447,7 @@ def _place_in_or_out_labels(
|
|
|
447
447
|
return outside
|
|
448
448
|
|
|
449
449
|
|
|
450
|
-
class HorizontalBarChart:
|
|
450
|
+
class HorizontalBarChart(FigureLifecycle):
|
|
451
451
|
"""Horizontal bar chart artist.
|
|
452
452
|
|
|
453
453
|
Parameters
|
|
@@ -507,6 +507,19 @@ class HorizontalBarChart:
|
|
|
507
507
|
outside; pragmatic but mixes the two looks).
|
|
508
508
|
"""
|
|
509
509
|
|
|
510
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
511
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
512
|
+
#: by ``tests/test_plotly_support.py``.
|
|
513
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
514
|
+
{
|
|
515
|
+
"color_value_labels",
|
|
516
|
+
"compact",
|
|
517
|
+
"figsize",
|
|
518
|
+
"font_pt",
|
|
519
|
+
"value_label_position",
|
|
520
|
+
}
|
|
521
|
+
)
|
|
522
|
+
|
|
510
523
|
def __init__(
|
|
511
524
|
self,
|
|
512
525
|
out_path: Path,
|
|
@@ -641,7 +654,11 @@ class HorizontalBarChart:
|
|
|
641
654
|
frame = frame.sort_values(by=_Col.VALUE.value)
|
|
642
655
|
return frame
|
|
643
656
|
|
|
644
|
-
def
|
|
657
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
658
|
+
"""Render the chart in memory; :meth:`save` writes it.
|
|
659
|
+
|
|
660
|
+
:meth:`plot` (from :class:`FigureLifecycle`) does both.
|
|
661
|
+
"""
|
|
645
662
|
"""Render the chart and persist it to ``self.out_path``."""
|
|
646
663
|
frame = self._build_frame()
|
|
647
664
|
|
|
@@ -689,14 +706,10 @@ class HorizontalBarChart:
|
|
|
689
706
|
axis="both", labelsize=font_pt
|
|
690
707
|
)
|
|
691
708
|
|
|
692
|
-
fig
|
|
693
|
-
self.out_path, dpi=dynamic_dpi(fig)
|
|
694
|
-
)
|
|
695
|
-
plt.close(fig)
|
|
696
|
-
return ax
|
|
709
|
+
return fig, ax
|
|
697
710
|
|
|
698
711
|
|
|
699
|
-
class GroupedBarChart:
|
|
712
|
+
class GroupedBarChart(FigureLifecycle):
|
|
700
713
|
"""Grouped (clustered) horizontal bar chart artist.
|
|
701
714
|
|
|
702
715
|
Compares two or more series side-by-side within each
|
|
@@ -764,6 +777,23 @@ class GroupedBarChart:
|
|
|
764
777
|
see :class:`HorizontalBarChart`.
|
|
765
778
|
"""
|
|
766
779
|
|
|
780
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
781
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
782
|
+
#: by ``tests/test_plotly_support.py``.
|
|
783
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
784
|
+
{
|
|
785
|
+
"annotate",
|
|
786
|
+
"color_value_labels",
|
|
787
|
+
"compact",
|
|
788
|
+
"figsize",
|
|
789
|
+
"font_pt",
|
|
790
|
+
"group_width",
|
|
791
|
+
"legend.ncol",
|
|
792
|
+
"plot_size",
|
|
793
|
+
"value_label_position",
|
|
794
|
+
}
|
|
795
|
+
)
|
|
796
|
+
|
|
767
797
|
def __init__(
|
|
768
798
|
self,
|
|
769
799
|
out_path: Path,
|
|
@@ -867,7 +897,11 @@ class GroupedBarChart:
|
|
|
867
897
|
|
|
868
898
|
return grouped_bar_to_figure(self)
|
|
869
899
|
|
|
870
|
-
def
|
|
900
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
901
|
+
"""Render the chart in memory; :meth:`save` writes it.
|
|
902
|
+
|
|
903
|
+
:meth:`plot` (from :class:`FigureLifecycle`) does both.
|
|
904
|
+
"""
|
|
871
905
|
"""Render the chart and persist it to ``self.out_path``."""
|
|
872
906
|
cat_ids = list(self.categories.keys())
|
|
873
907
|
ser_ids = list(self.series.keys())
|
|
@@ -941,8 +975,4 @@ class GroupedBarChart:
|
|
|
941
975
|
if self.plot_size is not None and not self.compact:
|
|
942
976
|
adjust_figure_for_plot_size(fig, ax, self.plot_size)
|
|
943
977
|
|
|
944
|
-
fig
|
|
945
|
-
self.out_path, dpi=dynamic_dpi(fig)
|
|
946
|
-
)
|
|
947
|
-
plt.close(fig)
|
|
948
|
-
return ax
|
|
978
|
+
return fig, ax
|
tesorotools/artists/box_plot.py
CHANGED
|
@@ -32,7 +32,7 @@ The last-value marker is a circle by default and fully configurable
|
|
|
32
32
|
from __future__ import annotations
|
|
33
33
|
|
|
34
34
|
from pathlib import Path
|
|
35
|
-
from typing import Any, Self
|
|
35
|
+
from typing import Any, ClassVar, Self
|
|
36
36
|
|
|
37
37
|
import matplotlib.pyplot as plt
|
|
38
38
|
import numpy as np
|
|
@@ -129,6 +129,21 @@ class BoxPlot:
|
|
|
129
129
|
* ``legend_labels`` -- override the four proxy labels.
|
|
130
130
|
"""
|
|
131
131
|
|
|
132
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
133
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
134
|
+
#: by ``tests/test_plotly_support.py``.
|
|
135
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
136
|
+
{
|
|
137
|
+
"compact",
|
|
138
|
+
"figsize",
|
|
139
|
+
"fmt.decimals",
|
|
140
|
+
"font_pt",
|
|
141
|
+
"legend.ncol",
|
|
142
|
+
"plot_size",
|
|
143
|
+
"x_rotation",
|
|
144
|
+
}
|
|
145
|
+
)
|
|
146
|
+
|
|
132
147
|
def __init__(
|
|
133
148
|
self,
|
|
134
149
|
out_path: Path,
|
tesorotools/artists/line_plot.py
CHANGED
|
@@ -11,8 +11,9 @@ from __future__ import annotations
|
|
|
11
11
|
|
|
12
12
|
import datetime
|
|
13
13
|
import locale
|
|
14
|
+
from collections.abc import Sequence
|
|
14
15
|
from pathlib import Path
|
|
15
|
-
from typing import Any, Self
|
|
16
|
+
from typing import Any, ClassVar, Self
|
|
16
17
|
|
|
17
18
|
import matplotlib.pyplot as plt
|
|
18
19
|
import pandas as pd
|
|
@@ -30,6 +31,7 @@ from tesorotools.artists._common import (
|
|
|
30
31
|
adjust_figure_for_plot_size,
|
|
31
32
|
annotate_last_values,
|
|
32
33
|
apply_date_axis,
|
|
34
|
+
check_y_limits,
|
|
33
35
|
compact_date_axis,
|
|
34
36
|
compact_figure_kwargs,
|
|
35
37
|
compact_font_pt,
|
|
@@ -160,12 +162,43 @@ class LinePlot:
|
|
|
160
162
|
**height** (keeping the width) so the plot area is not
|
|
161
163
|
squashed by a large legend or rotated date labels. Skipped
|
|
162
164
|
when ``plot_size`` is set (that already fixes the axes size).
|
|
165
|
+
* ``y_limits`` -- fix the y-axis to ``(bottom, top)`` on both
|
|
166
|
+
backends. The range is strict: the baseline, the reference
|
|
167
|
+
lines and the end-of-series labels no longer widen it.
|
|
168
|
+
* ``y_group`` -- name of a group of charts that must share the
|
|
169
|
+
same y scale (e.g. US and European equities, both base 100,
|
|
170
|
+
in two separate charts). It does nothing on its own:
|
|
171
|
+
:func:`tesorotools.artists.share_y_axes` measures every chart
|
|
172
|
+
of each group and sets the union of their ranges as their
|
|
173
|
+
``y_limits``. Mutually exclusive with ``y_limits``.
|
|
163
174
|
|
|
164
175
|
Render lifecycle: ``build()`` returns ``(Figure, Axes)``
|
|
165
176
|
in memory; ``save(fig)`` persists to ``out_path``;
|
|
166
177
|
``plot()`` is the thin convenience that does both.
|
|
167
178
|
"""
|
|
168
179
|
|
|
180
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
181
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
182
|
+
#: by ``tests/test_plotly_support.py``.
|
|
183
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
184
|
+
{
|
|
185
|
+
"adapt_height",
|
|
186
|
+
"axis_decimals",
|
|
187
|
+
"compact",
|
|
188
|
+
"date_density",
|
|
189
|
+
"date_rotation",
|
|
190
|
+
"date_show_start",
|
|
191
|
+
"figsize",
|
|
192
|
+
"font_pt",
|
|
193
|
+
"legend.ncol",
|
|
194
|
+
"means_window",
|
|
195
|
+
"plot_size",
|
|
196
|
+
"ref_lines",
|
|
197
|
+
"show_means",
|
|
198
|
+
"vlines",
|
|
199
|
+
}
|
|
200
|
+
)
|
|
201
|
+
|
|
169
202
|
def __init__(
|
|
170
203
|
self,
|
|
171
204
|
out_path: Path,
|
|
@@ -201,9 +234,16 @@ class LinePlot:
|
|
|
201
234
|
ref_lines: list[dict[str, Any]] | None = None,
|
|
202
235
|
show_means: bool = False,
|
|
203
236
|
means_window: tuple[Any, Any] | None = None,
|
|
237
|
+
y_limits: Sequence[float] | None = None,
|
|
238
|
+
y_group: str | None = None,
|
|
204
239
|
) -> None:
|
|
205
240
|
if out_path.suffix != ".png":
|
|
206
241
|
raise ValueError(f"out_path must be .png: {out_path}")
|
|
242
|
+
if y_limits is not None and y_group is not None:
|
|
243
|
+
raise ValueError(
|
|
244
|
+
"y_limits and y_group are mutually exclusive: a group "
|
|
245
|
+
"computes its shared y_limits (see share_y_axes)"
|
|
246
|
+
)
|
|
207
247
|
if base_100 and base_0:
|
|
208
248
|
raise ValueError(
|
|
209
249
|
"base_100 and base_0 are mutually exclusive: a series is "
|
|
@@ -247,6 +287,8 @@ class LinePlot:
|
|
|
247
287
|
self.ref_lines = ref_lines or []
|
|
248
288
|
self.show_means = show_means
|
|
249
289
|
self.means_window = means_window
|
|
290
|
+
self.y_limits = check_y_limits(y_limits)
|
|
291
|
+
self.y_group = y_group
|
|
250
292
|
|
|
251
293
|
@property
|
|
252
294
|
def _pin_start_date(self) -> bool:
|
|
@@ -302,7 +344,8 @@ class LinePlot:
|
|
|
302
344
|
``axis_decimals``, ``axis_units``, ``annotate_units``,
|
|
303
345
|
``date_density``, ``date_rotation``, ``date_show_start``,
|
|
304
346
|
``adapt_height``,
|
|
305
|
-
``ref_lines``, ``show_means``, ``means_window
|
|
347
|
+
``ref_lines``, ``show_means``, ``means_window``,
|
|
348
|
+
``y_limits``, ``y_group``.
|
|
306
349
|
|
|
307
350
|
Example
|
|
308
351
|
-------
|
|
@@ -440,6 +483,11 @@ class LinePlot:
|
|
|
440
483
|
# data point. annotate_last_values reopens room on the right.
|
|
441
484
|
ax.margins(x=0)
|
|
442
485
|
|
|
486
|
+
# a fixed (or group-shared) y range goes in BEFORE the annotations:
|
|
487
|
+
# they pack their labels at the final scale.
|
|
488
|
+
if self.y_limits is not None:
|
|
489
|
+
ax.set_ylim(*self.y_limits)
|
|
490
|
+
|
|
443
491
|
if self.vlines:
|
|
444
492
|
draw_vlines(ax, self.vlines)
|
|
445
493
|
|
|
@@ -472,6 +520,7 @@ class LinePlot:
|
|
|
472
520
|
headroom_pad_pt=(
|
|
473
521
|
compact_headroom_pad_pt() if self.compact else 0.0
|
|
474
522
|
),
|
|
523
|
+
grow_top=self.y_limits is None,
|
|
475
524
|
)
|
|
476
525
|
|
|
477
526
|
style_spines(
|
|
@@ -514,6 +563,10 @@ class LinePlot:
|
|
|
514
563
|
)
|
|
515
564
|
lo, hi = ax.get_ylim()
|
|
516
565
|
ax.set_ylim(min(ref_line["y"], lo), max(ref_line["y"], hi))
|
|
566
|
+
if self.y_limits is not None:
|
|
567
|
+
# the baseline and the reference lines widen the range to show
|
|
568
|
+
# themselves; a fixed range wins over them.
|
|
569
|
+
ax.set_ylim(*self.y_limits)
|
|
517
570
|
|
|
518
571
|
# per-series historical mean as a dotted same-colour line (the old
|
|
519
572
|
# `medias=True`): the mean is taken over means_window (or the whole
|
tesorotools/artists/matrix.py
CHANGED
|
@@ -27,7 +27,7 @@ Styling constants come from ``PLOT_CONFIG['matrix']``.
|
|
|
27
27
|
from __future__ import annotations
|
|
28
28
|
|
|
29
29
|
from pathlib import Path
|
|
30
|
-
from typing import Any, Self
|
|
30
|
+
from typing import Any, ClassVar, Self
|
|
31
31
|
|
|
32
32
|
import matplotlib as mpl
|
|
33
33
|
import matplotlib.pyplot as plt
|
|
@@ -40,9 +40,9 @@ from matplotlib.patches import Rectangle
|
|
|
40
40
|
from yaml.nodes import MappingNode
|
|
41
41
|
|
|
42
42
|
from tesorotools.artists._common import (
|
|
43
|
+
FigureLifecycle,
|
|
43
44
|
FIG_CONFIG,
|
|
44
45
|
Format,
|
|
45
|
-
dynamic_dpi,
|
|
46
46
|
resolve_data,
|
|
47
47
|
)
|
|
48
48
|
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
@@ -66,7 +66,7 @@ def _text_color(rgba: tuple[float, float, float, float]) -> str:
|
|
|
66
66
|
return MATRIX_CONFIG["dark_text"]
|
|
67
67
|
|
|
68
68
|
|
|
69
|
-
class MatrixChart:
|
|
69
|
+
class MatrixChart(FigureLifecycle):
|
|
70
70
|
"""Annotated heatmap-table artist (any ``n_rows x n_cols``).
|
|
71
71
|
|
|
72
72
|
Parameters
|
|
@@ -102,6 +102,16 @@ class MatrixChart:
|
|
|
102
102
|
cells read as tiles.
|
|
103
103
|
"""
|
|
104
104
|
|
|
105
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
106
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
107
|
+
#: by ``tests/test_plotly_support.py``.
|
|
108
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
109
|
+
{
|
|
110
|
+
"cmap",
|
|
111
|
+
"figsize",
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
105
115
|
def __init__(
|
|
106
116
|
self,
|
|
107
117
|
out_path: Path,
|
|
@@ -199,7 +209,11 @@ class MatrixChart:
|
|
|
199
209
|
|
|
200
210
|
return matrix_to_figure(self)
|
|
201
211
|
|
|
202
|
-
def
|
|
212
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
213
|
+
"""Render the chart in memory; :meth:`save` writes it.
|
|
214
|
+
|
|
215
|
+
:meth:`plot` (from :class:`FigureLifecycle`) does both.
|
|
216
|
+
"""
|
|
203
217
|
"""Render the chart and persist it to ``self.out_path``."""
|
|
204
218
|
row_ids = list(self.rows)
|
|
205
219
|
col_ids = list(self.cols)
|
|
@@ -260,11 +274,7 @@ class MatrixChart:
|
|
|
260
274
|
)
|
|
261
275
|
|
|
262
276
|
self._style_axes(ax, row_ids, col_ids)
|
|
263
|
-
fig
|
|
264
|
-
self.out_path, dpi=dynamic_dpi(fig)
|
|
265
|
-
)
|
|
266
|
-
plt.close(fig)
|
|
267
|
-
return ax
|
|
277
|
+
return fig, ax
|
|
268
278
|
|
|
269
279
|
def _annotate_cell(
|
|
270
280
|
self,
|
|
@@ -35,9 +35,11 @@ backends, per the dual-backend research):
|
|
|
35
35
|
``adapt_height``) do nothing here.
|
|
36
36
|
* Beyond those, a few per-artist knobs have no stable Plotly equivalent
|
|
37
37
|
and a few others are simply not translated yet. Both kinds are
|
|
38
|
-
accepted in silence
|
|
39
|
-
|
|
40
|
-
|
|
38
|
+
accepted in silence; each artist lists them in ``PLOTLY_UNSUPPORTED``
|
|
39
|
+
(read with :func:`tesorotools.artists.plotly_unsupported`), which
|
|
40
|
+
``tests/test_plotly_support.py`` checks against real renders. When
|
|
41
|
+
translating a key, drop it from there and from the table in
|
|
42
|
+
``docs/plotly-backend.md``.
|
|
41
43
|
"""
|
|
42
44
|
|
|
43
45
|
from __future__ import annotations
|
|
@@ -300,6 +302,9 @@ def line_plot_to_figure(chart: LinePlot) -> go.Figure:
|
|
|
300
302
|
# toggles (mirrors the matplotlib artist).
|
|
301
303
|
if chart.fmt.units and chart.axis_units:
|
|
302
304
|
fig.update_yaxes(ticksuffix=chart.fmt.units)
|
|
305
|
+
# fixed or group-shared y range (see share_y_axes)
|
|
306
|
+
if chart.y_limits is not None:
|
|
307
|
+
fig.update_yaxes(range=list(chart.y_limits))
|
|
303
308
|
_legend_side(fig, chart.legend)
|
|
304
309
|
return fig
|
|
305
310
|
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""One y scale shared by several separate charts.
|
|
2
|
+
|
|
3
|
+
Two charts that are read side by side (US and European equities, both
|
|
4
|
+
base 100) mislead when each picks its own y range: the same move looks
|
|
5
|
+
bigger in the flatter one. Charts that declare the same ``y_group``
|
|
6
|
+
are measured together here and all get the union of their ranges as a
|
|
7
|
+
fixed ``y_limits``, which both backends honour (``build()`` for the PNG,
|
|
8
|
+
``to_plotly()`` for the interactive figure).
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from collections.abc import Iterable
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
import matplotlib.pyplot as plt
|
|
17
|
+
|
|
18
|
+
__all__ = ["share_y_axes"]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def share_y_axes(charts: Iterable[Any]) -> dict[str, tuple[float, float]]:
|
|
22
|
+
"""Give every chart of each ``y_group`` the same ``y_limits``.
|
|
23
|
+
|
|
24
|
+
*charts* is any iterable of artists, typically the values of the
|
|
25
|
+
mapping loaded from a charts YAML; the ones without a ``y_group``
|
|
26
|
+
(or with ``None``) are left untouched. Each grouped chart is built
|
|
27
|
+
once with its own natural range (the one it would draw alone,
|
|
28
|
+
including the room its end-of-series labels open), and the group
|
|
29
|
+
gets the lowest bottom and the highest top among its members. Call
|
|
30
|
+
it before ``plot()`` / ``to_plotly()``; calling it again measures
|
|
31
|
+
afresh, so it is safe after the data change.
|
|
32
|
+
|
|
33
|
+
Returns the shared ``(bottom, top)`` per group name.
|
|
34
|
+
"""
|
|
35
|
+
groups: dict[str, list[Any]] = {}
|
|
36
|
+
for chart in charts:
|
|
37
|
+
group: Any = getattr(chart, "y_group", None)
|
|
38
|
+
if group is not None:
|
|
39
|
+
groups.setdefault(str(group), []).append(chart)
|
|
40
|
+
|
|
41
|
+
shared: dict[str, tuple[float, float]] = {}
|
|
42
|
+
for name, members in groups.items():
|
|
43
|
+
bottoms: list[float] = []
|
|
44
|
+
tops: list[float] = []
|
|
45
|
+
for chart in members:
|
|
46
|
+
# measure the chart's own range, not one shared earlier
|
|
47
|
+
chart.y_limits = None
|
|
48
|
+
fig, ax = chart.build()
|
|
49
|
+
try:
|
|
50
|
+
bottom, top = ax.get_ylim()
|
|
51
|
+
finally:
|
|
52
|
+
plt.close(fig)
|
|
53
|
+
bottoms.append(float(bottom))
|
|
54
|
+
tops.append(float(top))
|
|
55
|
+
limits = (min(bottoms), max(tops))
|
|
56
|
+
for chart in members:
|
|
57
|
+
chart.y_limits = limits
|
|
58
|
+
shared[name] = limits
|
|
59
|
+
return shared
|
|
@@ -30,7 +30,7 @@ from __future__ import annotations
|
|
|
30
30
|
|
|
31
31
|
from dataclasses import dataclass
|
|
32
32
|
from pathlib import Path
|
|
33
|
-
from typing import Any, Self, cast
|
|
33
|
+
from typing import Any, ClassVar, Self, cast
|
|
34
34
|
|
|
35
35
|
import matplotlib.pyplot as plt
|
|
36
36
|
import pandas as pd
|
|
@@ -124,6 +124,18 @@ class ShockChart:
|
|
|
124
124
|
value instead of each window's own anchor; must be a shock label.
|
|
125
125
|
"""
|
|
126
126
|
|
|
127
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
128
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
129
|
+
#: by ``tests/test_plotly_support.py``.
|
|
130
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
131
|
+
{
|
|
132
|
+
"compact",
|
|
133
|
+
"figsize",
|
|
134
|
+
"fmt.decimals",
|
|
135
|
+
"legend.ncol",
|
|
136
|
+
}
|
|
137
|
+
)
|
|
138
|
+
|
|
127
139
|
def __init__(
|
|
128
140
|
self,
|
|
129
141
|
out_path: Path,
|
tesorotools/artists/stacked.py
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Any, Self
|
|
4
|
+
from typing import Any, ClassVar, Self
|
|
5
5
|
|
|
6
6
|
import matplotlib.pyplot as plt
|
|
7
7
|
import numpy as np
|
|
@@ -11,6 +11,7 @@ from matplotlib.figure import Figure
|
|
|
11
11
|
from yaml.nodes import MappingNode
|
|
12
12
|
|
|
13
13
|
from tesorotools.artists._common import (
|
|
14
|
+
FigureLifecycle,
|
|
14
15
|
AX_CONFIG,
|
|
15
16
|
Format,
|
|
16
17
|
Legend,
|
|
@@ -22,7 +23,6 @@ from tesorotools.artists._common import (
|
|
|
22
23
|
compact_font_pt,
|
|
23
24
|
date_maxticks,
|
|
24
25
|
date_show_start_default,
|
|
25
|
-
dynamic_dpi,
|
|
26
26
|
legend_layout,
|
|
27
27
|
place_legend,
|
|
28
28
|
style_baseline,
|
|
@@ -32,7 +32,7 @@ from tesorotools.artists._common import (
|
|
|
32
32
|
from tesorotools.utils.template import TemplateLoader
|
|
33
33
|
|
|
34
34
|
|
|
35
|
-
class StackedAreaPlot:
|
|
35
|
+
class StackedAreaPlot(FigureLifecycle):
|
|
36
36
|
"""Stacked area chart with the tesorotools visual style.
|
|
37
37
|
|
|
38
38
|
Parameters match ``LinePlot`` where applicable so chart
|
|
@@ -47,6 +47,21 @@ class StackedAreaPlot:
|
|
|
47
47
|
the concise style.
|
|
48
48
|
"""
|
|
49
49
|
|
|
50
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
51
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
52
|
+
#: by ``tests/test_plotly_support.py``.
|
|
53
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
54
|
+
{
|
|
55
|
+
"compact",
|
|
56
|
+
"date_show_start",
|
|
57
|
+
"figsize",
|
|
58
|
+
"fmt.decimals",
|
|
59
|
+
"font_pt",
|
|
60
|
+
"legend.ncol",
|
|
61
|
+
"plot_size",
|
|
62
|
+
}
|
|
63
|
+
)
|
|
64
|
+
|
|
50
65
|
def __init__(
|
|
51
66
|
self,
|
|
52
67
|
out_path: Path,
|
|
@@ -159,7 +174,11 @@ class StackedAreaPlot:
|
|
|
159
174
|
|
|
160
175
|
return stacked_area_to_figure(self)
|
|
161
176
|
|
|
162
|
-
def
|
|
177
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
178
|
+
"""Render the chart in memory; :meth:`save` writes it.
|
|
179
|
+
|
|
180
|
+
:meth:`plot` (from :class:`FigureLifecycle`) does both.
|
|
181
|
+
"""
|
|
163
182
|
plot_data = self._prepare_data()
|
|
164
183
|
|
|
165
184
|
font_pt = compact_font_pt(self.font_pt) if self.compact else None
|
|
@@ -223,14 +242,10 @@ class StackedAreaPlot:
|
|
|
223
242
|
if self.plot_size is not None and not self.compact:
|
|
224
243
|
adjust_figure_for_plot_size(fig, ax, self.plot_size)
|
|
225
244
|
|
|
226
|
-
fig
|
|
227
|
-
self.out_path, dpi=dynamic_dpi(fig)
|
|
228
|
-
)
|
|
229
|
-
plt.close(fig)
|
|
230
|
-
return ax
|
|
245
|
+
return fig, ax
|
|
231
246
|
|
|
232
247
|
|
|
233
|
-
class StackedBarPlot:
|
|
248
|
+
class StackedBarPlot(FigureLifecycle):
|
|
234
249
|
"""Stacked bar chart with the tesorotools visual style.
|
|
235
250
|
|
|
236
251
|
Positive and negative values are stacked separately so
|
|
@@ -240,6 +255,22 @@ class StackedBarPlot:
|
|
|
240
255
|
reimplementing the full render pipeline.
|
|
241
256
|
"""
|
|
242
257
|
|
|
258
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
259
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
260
|
+
#: by ``tests/test_plotly_support.py``.
|
|
261
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
262
|
+
{
|
|
263
|
+
"bar_width",
|
|
264
|
+
"compact",
|
|
265
|
+
"figsize",
|
|
266
|
+
"fmt.decimals",
|
|
267
|
+
"font_pt",
|
|
268
|
+
"legend.ncol",
|
|
269
|
+
"plot_size",
|
|
270
|
+
"x_rotation",
|
|
271
|
+
}
|
|
272
|
+
)
|
|
273
|
+
|
|
243
274
|
def __init__(
|
|
244
275
|
self,
|
|
245
276
|
out_path: Path,
|
|
@@ -391,7 +422,11 @@ class StackedBarPlot:
|
|
|
391
422
|
|
|
392
423
|
return stacked_bar_to_figure(self)
|
|
393
424
|
|
|
394
|
-
def
|
|
425
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
426
|
+
"""Render the chart in memory; :meth:`save` writes it.
|
|
427
|
+
|
|
428
|
+
:meth:`plot` (from :class:`FigureLifecycle`) does both.
|
|
429
|
+
"""
|
|
395
430
|
plot_data = self._prepare_data()
|
|
396
431
|
|
|
397
432
|
font_pt = compact_font_pt(self.font_pt) if self.compact else None
|
|
@@ -478,8 +513,4 @@ class StackedBarPlot:
|
|
|
478
513
|
if self.plot_size is not None and not self.compact:
|
|
479
514
|
adjust_figure_for_plot_size(fig, ax, self.plot_size)
|
|
480
515
|
|
|
481
|
-
fig
|
|
482
|
-
self.out_path, dpi=dynamic_dpi(fig)
|
|
483
|
-
)
|
|
484
|
-
plt.close(fig)
|
|
485
|
-
return ax
|
|
516
|
+
return fig, ax
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
3
|
from pathlib import Path
|
|
4
|
-
from typing import Any, Self
|
|
4
|
+
from typing import Any, ClassVar, Self
|
|
5
5
|
|
|
6
6
|
import matplotlib.pyplot as plt
|
|
7
7
|
import pandas as pd
|
|
@@ -11,13 +11,13 @@ from pandas import Timestamp
|
|
|
11
11
|
from yaml.nodes import MappingNode
|
|
12
12
|
|
|
13
13
|
from tesorotools.artists._common import (
|
|
14
|
+
FigureLifecycle,
|
|
14
15
|
AX_CONFIG,
|
|
15
16
|
Format,
|
|
16
17
|
Legend,
|
|
17
18
|
resolve_data,
|
|
18
19
|
compact_figure_kwargs,
|
|
19
20
|
compact_font_pt,
|
|
20
|
-
dynamic_dpi,
|
|
21
21
|
legend_band,
|
|
22
22
|
legend_layout,
|
|
23
23
|
place_legend,
|
|
@@ -99,7 +99,7 @@ def _format_data(data: pd.DataFrame) -> dict[str, Any]:
|
|
|
99
99
|
}
|
|
100
100
|
|
|
101
101
|
|
|
102
|
-
class TypeCurve:
|
|
102
|
+
class TypeCurve(FigureLifecycle):
|
|
103
103
|
"""Type-curve chart with the tesorotools visual style.
|
|
104
104
|
|
|
105
105
|
Plots two ranges (last year and current year) as filled
|
|
@@ -111,6 +111,18 @@ class TypeCurve:
|
|
|
111
111
|
width, marker) come from ``PLOT_CONFIG['type_curve']``.
|
|
112
112
|
"""
|
|
113
113
|
|
|
114
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
115
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
116
|
+
#: by ``tests/test_plotly_support.py``.
|
|
117
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
118
|
+
{
|
|
119
|
+
"compact",
|
|
120
|
+
"figsize",
|
|
121
|
+
"font_pt",
|
|
122
|
+
"legend.ncol",
|
|
123
|
+
}
|
|
124
|
+
)
|
|
125
|
+
|
|
114
126
|
def __init__(
|
|
115
127
|
self,
|
|
116
128
|
out_path: Path,
|
|
@@ -215,7 +227,11 @@ class TypeCurve:
|
|
|
215
227
|
|
|
216
228
|
return type_curve_to_figure(self)
|
|
217
229
|
|
|
218
|
-
def
|
|
230
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
231
|
+
"""Render the chart in memory; :meth:`save` writes it.
|
|
232
|
+
|
|
233
|
+
:meth:`plot` (from :class:`FigureLifecycle`) does both.
|
|
234
|
+
"""
|
|
219
235
|
plot_data = self.data.loc[:, list(self.series.keys())]
|
|
220
236
|
plot_data = plot_data.rename(columns=self.series)
|
|
221
237
|
plot_data = plot_data * self.scale
|
|
@@ -327,8 +343,4 @@ class TypeCurve:
|
|
|
327
343
|
compact=False,
|
|
328
344
|
),
|
|
329
345
|
)
|
|
330
|
-
fig
|
|
331
|
-
self.out_path, dpi=dynamic_dpi(fig)
|
|
332
|
-
)
|
|
333
|
-
plt.close(fig)
|
|
334
|
-
return ax
|
|
346
|
+
return fig, ax
|
|
@@ -26,7 +26,7 @@ constants come from ``PLOT_CONFIG['vector']``.
|
|
|
26
26
|
from __future__ import annotations
|
|
27
27
|
|
|
28
28
|
from pathlib import Path
|
|
29
|
-
from typing import Any, Self
|
|
29
|
+
from typing import Any, ClassVar, Self
|
|
30
30
|
|
|
31
31
|
import matplotlib.pyplot as plt
|
|
32
32
|
import pandas as pd
|
|
@@ -36,10 +36,10 @@ from matplotlib.ticker import FuncFormatter
|
|
|
36
36
|
from yaml.nodes import MappingNode
|
|
37
37
|
|
|
38
38
|
from tesorotools.artists._common import (
|
|
39
|
+
FigureLifecycle,
|
|
39
40
|
AX_CONFIG,
|
|
40
41
|
FIG_CONFIG,
|
|
41
42
|
Format,
|
|
42
|
-
dynamic_dpi,
|
|
43
43
|
resolve_data,
|
|
44
44
|
)
|
|
45
45
|
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
@@ -50,7 +50,7 @@ __all__ = ["VectorPlot"]
|
|
|
50
50
|
VECTOR_CONFIG: dict[str, Any] = PLOT_CONFIG["vector"]
|
|
51
51
|
|
|
52
52
|
|
|
53
|
-
class VectorPlot:
|
|
53
|
+
class VectorPlot(FigureLifecycle):
|
|
54
54
|
"""Arrow chart showing the change in two variables.
|
|
55
55
|
|
|
56
56
|
Each entity in ``series`` is rendered as an arrow from a
|
|
@@ -94,6 +94,17 @@ class VectorPlot:
|
|
|
94
94
|
reproduces the dashed arrows used for outliers).
|
|
95
95
|
"""
|
|
96
96
|
|
|
97
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
98
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
99
|
+
#: by ``tests/test_plotly_support.py``.
|
|
100
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
101
|
+
{
|
|
102
|
+
"figsize",
|
|
103
|
+
"x_fmt.decimals",
|
|
104
|
+
"y_fmt.decimals",
|
|
105
|
+
}
|
|
106
|
+
)
|
|
107
|
+
|
|
97
108
|
def __init__(
|
|
98
109
|
self,
|
|
99
110
|
out_path: Path,
|
|
@@ -205,7 +216,11 @@ class VectorPlot:
|
|
|
205
216
|
y0 = float(row[self.y_start]) * self.y_scale if self.y_start else 0.0
|
|
206
217
|
return x0, y0, x1, y1
|
|
207
218
|
|
|
208
|
-
def
|
|
219
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
220
|
+
"""Render the chart in memory; :meth:`save` writes it.
|
|
221
|
+
|
|
222
|
+
:meth:`plot` (from :class:`FigureLifecycle`) does both.
|
|
223
|
+
"""
|
|
209
224
|
needed = [self.x, self.y]
|
|
210
225
|
if self.x_start is not None:
|
|
211
226
|
needed.append(self.x_start)
|
|
@@ -270,11 +285,7 @@ class VectorPlot:
|
|
|
270
285
|
)
|
|
271
286
|
|
|
272
287
|
self._style_axes(ax, xs, ys)
|
|
273
|
-
fig
|
|
274
|
-
self.out_path, dpi=dynamic_dpi(fig)
|
|
275
|
-
)
|
|
276
|
-
plt.close(fig)
|
|
277
|
-
return ax
|
|
288
|
+
return fig, ax
|
|
278
289
|
|
|
279
290
|
def _style_axes(self, ax: Axes, xs: list[float], ys: list[float]) -> None:
|
|
280
291
|
"""Frame, origin cross-hairs, tick formatters and labels."""
|
tesorotools/artists/waterfall.py
CHANGED
|
@@ -17,7 +17,7 @@ row inside a Word table, reusing the shared compact helpers in
|
|
|
17
17
|
from __future__ import annotations
|
|
18
18
|
|
|
19
19
|
from pathlib import Path
|
|
20
|
-
from typing import Any, Self
|
|
20
|
+
from typing import Any, ClassVar, Self
|
|
21
21
|
|
|
22
22
|
import matplotlib.pyplot as plt
|
|
23
23
|
import numpy as np
|
|
@@ -58,6 +58,18 @@ class Waterfall:
|
|
|
58
58
|
last categories (opening and closing balance).
|
|
59
59
|
"""
|
|
60
60
|
|
|
61
|
+
#: Keys that change the PNG but never the ``to_plotly()`` figure
|
|
62
|
+
#: (see :func:`plotly_unsupported`); checked against real renders
|
|
63
|
+
#: by ``tests/test_plotly_support.py``.
|
|
64
|
+
PLOTLY_UNSUPPORTED: ClassVar[frozenset[str]] = frozenset(
|
|
65
|
+
{
|
|
66
|
+
"compact",
|
|
67
|
+
"figsize",
|
|
68
|
+
"font_pt",
|
|
69
|
+
"plot_size",
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
|
|
61
73
|
def __init__(
|
|
62
74
|
self,
|
|
63
75
|
out_path: Path,
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
tesorotools/__init__.py,sha256=
|
|
1
|
+
tesorotools/__init__.py,sha256=BV31YV_qabD4vdXzoKb89MxXO386YE0xFjXqpDZSXaE,5334
|
|
2
2
|
tesorotools/_build_context.py,sha256=MH2AKjkwuubN__sBQoXbfTD1bh5RE6w9l5yUFxkJxy8,1982
|
|
3
3
|
tesorotools/_registry.py,sha256=ZJOr7jACxykvzoepebZDiJdTcR37Q3X-DQ07lm9ZYB0,7975
|
|
4
4
|
tesorotools/driver.py,sha256=qEVhLYanbG7VXe3WrCCD0RJV_0X4xzhalrfABPB1gSI,5016
|
|
@@ -7,21 +7,22 @@ tesorotools/orchestration.py,sha256=24C-LTM4uy7eckjXBjJbC2t92fg6yffl5H-E9Gm2W9M,
|
|
|
7
7
|
tesorotools/palette.py,sha256=fkf1_AxweRzviwKLb5zSZnWwP_Cr4zEYoqJ4jqIfBoE,1944
|
|
8
8
|
tesorotools/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
9
9
|
tesorotools/scaffold.py,sha256=6mYNxLm_1CbEMjcYZ77m_2H1eTotbpatFJqhgyote0s,4578
|
|
10
|
-
tesorotools/artists/__init__.py,sha256
|
|
11
|
-
tesorotools/artists/_common.py,sha256=
|
|
12
|
-
tesorotools/artists/bar_line.py,sha256=
|
|
13
|
-
tesorotools/artists/barh_plot.py,sha256=
|
|
14
|
-
tesorotools/artists/box_plot.py,sha256=
|
|
10
|
+
tesorotools/artists/__init__.py,sha256=-w2IzVdT7iatJien_5JU9fHepx2kTcYcYgrjQXWZYRI,2492
|
|
11
|
+
tesorotools/artists/_common.py,sha256=k59YvyxIlTSEUPU6-koGksOdEKvDcwjhsdPV0LxRCLo,59272
|
|
12
|
+
tesorotools/artists/bar_line.py,sha256=4urcPuHEfohl4WI3pkb2I__1WZ2W73lDiGoqlwFA27w,14477
|
|
13
|
+
tesorotools/artists/barh_plot.py,sha256=n8DjIX1APCVS7HW4zzfh6ehggdgBqb6sezQHov1k7us,37520
|
|
14
|
+
tesorotools/artists/box_plot.py,sha256=KPQjbY2H5JWJWF_icApqpvE1f6sE4e56neIS_dRL6w8,20373
|
|
15
15
|
tesorotools/artists/compact.py,sha256=e_0tqkRSIgyiyMSwirJzj2TYSKojMxUHzug5tE_7toc,3003
|
|
16
16
|
tesorotools/artists/intraday_plot.py,sha256=TO-lKwwbIPxx4T-ObYHNuGGR3F_-GZ6XQGwrYU6tyL8,13259
|
|
17
|
-
tesorotools/artists/line_plot.py,sha256=
|
|
18
|
-
tesorotools/artists/matrix.py,sha256=
|
|
19
|
-
tesorotools/artists/plotly_backend.py,sha256=
|
|
20
|
-
tesorotools/artists/
|
|
21
|
-
tesorotools/artists/
|
|
22
|
-
tesorotools/artists/
|
|
23
|
-
tesorotools/artists/
|
|
24
|
-
tesorotools/artists/
|
|
17
|
+
tesorotools/artists/line_plot.py,sha256=yqg7dHzzV_xfiwiWIlvNN6DVXQ9wjRBdOsiJYvG0iJY,31157
|
|
18
|
+
tesorotools/artists/matrix.py,sha256=cOpj9ZO5RR0-RlIP55UmD70i-738ck8wZIqRiDO22Hw,11785
|
|
19
|
+
tesorotools/artists/plotly_backend.py,sha256=yaRnZayxgTbNVuiJW1p5l10mo5UFMTslA_QK-nBkZsM,37067
|
|
20
|
+
tesorotools/artists/sharing.py,sha256=Tg4AYtv0ileKQmzBSgmT1GzxA97tdU8ji_arE0oGEZ4,2236
|
|
21
|
+
tesorotools/artists/shock_plot.py,sha256=Vx9RRvRaJZIXh8G7oJSRRc1XtPgGkqA6xaVk4h4oJuE,13435
|
|
22
|
+
tesorotools/artists/stacked.py,sha256=MyItTb8NMOpR6jDclqjmofhAB-L9mD1vnzGQjXUTFBc,17439
|
|
23
|
+
tesorotools/artists/type_curve.py,sha256=0j1LjfS4Zdz4_W7qp-nY8VP0H-fENV5adUg4syAOvig,12570
|
|
24
|
+
tesorotools/artists/vector_plot.py,sha256=P7BtLCXRTWs3eepRFYvXzbJ9OkvB-Ya3kmXB6yJSkR0,12211
|
|
25
|
+
tesorotools/artists/waterfall.py,sha256=S2w6LuMb9P71FA9aUygGd6KRAEQx8tfGps267YmLlSQ,12348
|
|
25
26
|
tesorotools/assets/README.md,sha256=EjDuA4Xf-Omn0Khp8erNXdW-iyWBzSM2FQFexdrS6Eg,285
|
|
26
27
|
tesorotools/assets/plots.yaml,sha256=shIDoQTYveZ7IWnG_5QAXESzq9trcRpkjtry2yXQsE0,11162
|
|
27
28
|
tesorotools/assets/template.docx,sha256=QYilJmJ-ltGPH_GH22j6Kis7wmNaAgFQXr68d1KUSHY,94394
|
|
@@ -129,7 +130,7 @@ tesorotools/web/templates/src/$package/main.py.tmpl,sha256=GXG3AA87WOjk5nTKA5Ywm
|
|
|
129
130
|
tesorotools/web/templates/src/$package/sections.py.tmpl,sha256=4MNZ85AW5ERG1SrkVAXIwdmAMeQEBARCYdAMqUr37mg,2084
|
|
130
131
|
tesorotools/web/templates/tests/__init__.py.tmpl,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
131
132
|
tesorotools/web/templates/tests/test_app.py.tmpl,sha256=ZGfV5GPN6WsS3a2gCNLPWejkHTX2H_N9XpurIADuUxI,615
|
|
132
|
-
tesorotools_python-0.1.
|
|
133
|
-
tesorotools_python-0.1.
|
|
134
|
-
tesorotools_python-0.1.
|
|
135
|
-
tesorotools_python-0.1.
|
|
133
|
+
tesorotools_python-0.1.2.dist-info/METADATA,sha256=ARJoWsUX5H4GXfcoU7EH_sVTr5aUusS-9OpkhfTVm-A,993
|
|
134
|
+
tesorotools_python-0.1.2.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
135
|
+
tesorotools_python-0.1.2.dist-info/entry_points.txt,sha256=PuNrObu4SVvajBHTASuWZPqwSsV7tPHwFJBzYk2nY5o,108
|
|
136
|
+
tesorotools_python-0.1.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|