tesorotools-python 0.0.46__tar.gz → 0.0.47__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.
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/.gitignore +3 -1
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/PKG-INFO +1 -1
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/__init__.py +3 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/__init__.py +2 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/_common.py +136 -5
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/line_plot.py +88 -15
- tesorotools_python-0.0.47/src/tesorotools/artists/waterfall.py +322 -0
- tesorotools_python-0.0.47/src/tesorotools/assets/plots.yaml +95 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/tesoro.mplstyle +2 -2
- tesorotools_python-0.0.46/src/tesorotools/assets/plots.yaml +0 -68
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/pyproject.toml +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/_build_context.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/_registry.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/barh_plot.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/matrix.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/stacked.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/type_curve.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/vector_plot.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/README.md +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/README.md +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/data_sources/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/data_sources/debug.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/database/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/database/local.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/database/push.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/database/shared.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/node.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/resolution.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/driver.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/manifest.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/offsets/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/offsets/offsets.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/offsets/outliers.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/orchestration.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/diagnose.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/engine.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/rules.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/base.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/bde.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/ecb.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/imf_irfcl.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/lseg.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/py.typed +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/content.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/images.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/section.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/subtitle.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/table.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/text.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/title.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/report.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/testing/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/testing/compare.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/__init__.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/config.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/format.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/globals.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/matplotlib.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/series.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/shortcuts.py +0 -0
- {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/template.py +0 -0
|
@@ -54,6 +54,7 @@ from tesorotools.artists import (
|
|
|
54
54
|
StackedBarPlot,
|
|
55
55
|
TypeCurve,
|
|
56
56
|
VectorPlot,
|
|
57
|
+
Waterfall,
|
|
57
58
|
)
|
|
58
59
|
from tesorotools.orchestration import CompositeRegistry, iter_contexts
|
|
59
60
|
from tesorotools.providers.base import (
|
|
@@ -83,6 +84,7 @@ def _register_builtins() -> None:
|
|
|
83
84
|
register_artist("type_curve", TypeCurve)
|
|
84
85
|
register_artist("vector_plot", VectorPlot)
|
|
85
86
|
register_artist("matrix", MatrixChart)
|
|
87
|
+
register_artist("waterfall", Waterfall)
|
|
86
88
|
|
|
87
89
|
register_tag("format", Format)
|
|
88
90
|
register_tag("legend", Legend)
|
|
@@ -127,6 +129,7 @@ __all__ = [
|
|
|
127
129
|
"Title",
|
|
128
130
|
"TypeCurve",
|
|
129
131
|
"VectorPlot",
|
|
132
|
+
"Waterfall",
|
|
130
133
|
"YamlConstructor",
|
|
131
134
|
"all_artists",
|
|
132
135
|
"all_providers",
|
|
@@ -33,6 +33,7 @@ from tesorotools.artists.matrix import MatrixChart
|
|
|
33
33
|
from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
|
|
34
34
|
from tesorotools.artists.type_curve import TypeCurve
|
|
35
35
|
from tesorotools.artists.vector_plot import VectorPlot
|
|
36
|
+
from tesorotools.artists.waterfall import Waterfall
|
|
36
37
|
from tesorotools.utils.globals import STYLE_SHEET
|
|
37
38
|
|
|
38
39
|
matplotlib.style.use(STYLE_SHEET)
|
|
@@ -49,4 +50,5 @@ __all__ = [
|
|
|
49
50
|
"StackedBarPlot",
|
|
50
51
|
"TypeCurve",
|
|
51
52
|
"VectorPlot",
|
|
53
|
+
"Waterfall",
|
|
52
54
|
]
|
|
@@ -29,9 +29,14 @@ from typing import Any, Protocol, Self, cast, runtime_checkable
|
|
|
29
29
|
import matplotlib.pyplot as plt
|
|
30
30
|
import pandas as pd
|
|
31
31
|
from matplotlib.axes import Axes
|
|
32
|
+
from matplotlib.dates import AutoDateLocator, ConciseDateFormatter
|
|
33
|
+
from matplotlib.dates import (
|
|
34
|
+
date2num, # type: ignore[reportUnknownVariableType]
|
|
35
|
+
)
|
|
32
36
|
from matplotlib.figure import Figure
|
|
33
37
|
from matplotlib.font_manager import FontProperties
|
|
34
|
-
from matplotlib.
|
|
38
|
+
from matplotlib.legend import Legend as MplLegend
|
|
39
|
+
from matplotlib.ticker import FixedLocator, FuncFormatter
|
|
35
40
|
from yaml.nodes import MappingNode
|
|
36
41
|
|
|
37
42
|
from tesorotools.utils.matplotlib import (
|
|
@@ -45,6 +50,9 @@ load_fonts()
|
|
|
45
50
|
|
|
46
51
|
AX_CONFIG: dict[str, Any] = PLOT_CONFIG["ax"]
|
|
47
52
|
FIG_CONFIG: dict[str, Any] = PLOT_CONFIG["figure"]
|
|
53
|
+
COMPACT_CONFIG: dict[str, Any] = PLOT_CONFIG.get("compact", {})
|
|
54
|
+
|
|
55
|
+
CM_TO_IN: float = 1.0 / 2.54
|
|
48
56
|
|
|
49
57
|
|
|
50
58
|
# ----------------------------------------------------------------------
|
|
@@ -295,6 +303,117 @@ def export_legend(
|
|
|
295
303
|
legend.remove()
|
|
296
304
|
|
|
297
305
|
|
|
306
|
+
# ----------------------------------------------------------------------
|
|
307
|
+
# Compact mode (charts that live inside a Word table, two per row)
|
|
308
|
+
# ----------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def compact_figsize() -> tuple[float, float]:
|
|
312
|
+
"""Return the compact figure size ``(width, height)`` in inches.
|
|
313
|
+
|
|
314
|
+
The width is the **total** figure width (chart + legend),
|
|
315
|
+
read from the ``compact`` section of ``plots.yaml`` in
|
|
316
|
+
centimetres and converted to inches. Defaults to
|
|
317
|
+
7.4 cm x 5.5 cm when the config is absent.
|
|
318
|
+
"""
|
|
319
|
+
width_cm: float = float(COMPACT_CONFIG.get("width_cm", 7.4))
|
|
320
|
+
height_cm: float = float(COMPACT_CONFIG.get("height_cm", 5.5))
|
|
321
|
+
return (width_cm * CM_TO_IN, height_cm * CM_TO_IN)
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def compact_font_pt() -> float | None:
|
|
325
|
+
"""Font size (points) for compact charts, or ``None`` to keep the style.
|
|
326
|
+
|
|
327
|
+
Because the compact figure is rendered at its true
|
|
328
|
+
physical size (7.4 cm) and embedded without rescaling,
|
|
329
|
+
these points equal the points the reader sees in Word --
|
|
330
|
+
set ``compact.font_pt`` in ``plots.yaml`` to the document
|
|
331
|
+
body-text size so chart text matches running text.
|
|
332
|
+
"""
|
|
333
|
+
raw: Any = COMPACT_CONFIG.get("font_pt")
|
|
334
|
+
return None if raw is None else float(raw)
|
|
335
|
+
|
|
336
|
+
|
|
337
|
+
def compact_date_axis(
|
|
338
|
+
ax: Axes,
|
|
339
|
+
data_min: Any,
|
|
340
|
+
data_max: Any,
|
|
341
|
+
*,
|
|
342
|
+
maxticks: int = 4,
|
|
343
|
+
) -> None:
|
|
344
|
+
"""Compact date x-axis: few horizontal ticks, last one near the data end.
|
|
345
|
+
|
|
346
|
+
Ticks are computed over the **data span** ``[data_min,
|
|
347
|
+
data_max]`` and pinned with a ``FixedLocator``, so none
|
|
348
|
+
float in the right-hand margin that the value labels open
|
|
349
|
+
up -- the last tick lands as close to the final data
|
|
350
|
+
point as the tick step allows. Requires the series to
|
|
351
|
+
have been plotted with ``x_compat=True`` (pandas' own
|
|
352
|
+
date plotting ignores this formatter).
|
|
353
|
+
"""
|
|
354
|
+
lo: float = float(cast(Any, date2num(data_min)))
|
|
355
|
+
hi: float = float(cast(Any, date2num(data_max)))
|
|
356
|
+
locator = AutoDateLocator(minticks=2, maxticks=maxticks)
|
|
357
|
+
candidates: list[float] = cast(
|
|
358
|
+
"list[float]", locator.tick_values(data_min, data_max)
|
|
359
|
+
)
|
|
360
|
+
ticks: list[float] = [t for t in candidates if lo <= t <= hi]
|
|
361
|
+
if not ticks:
|
|
362
|
+
ticks = [lo, hi]
|
|
363
|
+
fixed = FixedLocator(ticks)
|
|
364
|
+
ax.xaxis.set_major_locator(fixed)
|
|
365
|
+
ax.xaxis.set_major_formatter(ConciseDateFormatter(fixed))
|
|
366
|
+
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
367
|
+
axis="x", rotation=0
|
|
368
|
+
)
|
|
369
|
+
for label in ax.get_xticklabels():
|
|
370
|
+
label.set_horizontalalignment("center")
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def legend_above(
|
|
374
|
+
ax: Axes,
|
|
375
|
+
handles: list[Any],
|
|
376
|
+
labels: list[str],
|
|
377
|
+
*,
|
|
378
|
+
ncol: int,
|
|
379
|
+
fontsize: float | None = None,
|
|
380
|
+
) -> MplLegend:
|
|
381
|
+
"""Place the legend **above** the axes, expanded to its full width.
|
|
382
|
+
|
|
383
|
+
Uses ``mode="expand"`` so the legend spans exactly the
|
|
384
|
+
plot width (never wider) and ``bbox_to_anchor`` from the
|
|
385
|
+
``compact`` config (default ``(0, 1.06, 1, 0.12)`` -- the
|
|
386
|
+
``y0 > 1`` lifts it clear of the axes). The legend
|
|
387
|
+
becomes a child of the axes, so constrained layout
|
|
388
|
+
reserves room for it inside the fixed compact figure.
|
|
389
|
+
"""
|
|
390
|
+
bbox: Any = COMPACT_CONFIG.get("legend_bbox", [0.0, 1.06, 1.0, 0.12])
|
|
391
|
+
return ax.legend( # type: ignore[reportUnknownMemberType]
|
|
392
|
+
handles,
|
|
393
|
+
labels,
|
|
394
|
+
loc="lower left",
|
|
395
|
+
bbox_to_anchor=tuple(bbox),
|
|
396
|
+
mode="expand",
|
|
397
|
+
borderaxespad=0.0,
|
|
398
|
+
ncol=ncol,
|
|
399
|
+
fontsize=fontsize,
|
|
400
|
+
frameon=False, # no box: less clutter in the tight compact figure
|
|
401
|
+
)
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def declutter_spines(ax: Axes) -> None:
|
|
405
|
+
"""Hide the spines that carry no ticks (top and right).
|
|
406
|
+
|
|
407
|
+
Compact line charts move the y-ticks to the **left** (so
|
|
408
|
+
the right edge is free for the end-of-series value labels)
|
|
409
|
+
and keep x-ticks on the bottom, leaving the top and right
|
|
410
|
+
spines as pure clutter -- drop them to free space and
|
|
411
|
+
reduce visual noise.
|
|
412
|
+
"""
|
|
413
|
+
ax.spines["top"].set_visible(False)
|
|
414
|
+
ax.spines["right"].set_visible(False)
|
|
415
|
+
|
|
416
|
+
|
|
298
417
|
# ----------------------------------------------------------------------
|
|
299
418
|
# Cartesian (vertical-y) styling
|
|
300
419
|
# ----------------------------------------------------------------------
|
|
@@ -378,6 +497,9 @@ def annotate_last_values(
|
|
|
378
497
|
labels: dict[str, str] | None = None,
|
|
379
498
|
series_styles: dict[str, dict[str, Any]] | None = None,
|
|
380
499
|
annotate_color: str | None = None,
|
|
500
|
+
weight: str | None = None,
|
|
501
|
+
fontsize: float | None = None,
|
|
502
|
+
right_pad_px: float = 10.0,
|
|
381
503
|
) -> None:
|
|
382
504
|
"""Label the last non-NaN value of each column on the right.
|
|
383
505
|
|
|
@@ -392,8 +514,12 @@ def annotate_last_values(
|
|
|
392
514
|
|
|
393
515
|
Labels are packed vertically in display space so that
|
|
394
516
|
series ending at nearly the same value do not overlap.
|
|
395
|
-
|
|
396
|
-
|
|
517
|
+
They sit just right of the last point and the x-axis
|
|
518
|
+
limit is extended by the widest label plus ``right_pad_px``
|
|
519
|
+
so the text is not clipped by the axes frame; raise
|
|
520
|
+
``right_pad_px`` (e.g. on compact charts) to open more
|
|
521
|
+
blank space on the right and keep the labels clear of both
|
|
522
|
+
the data lines and the right-hand y-tick labels.
|
|
397
523
|
"""
|
|
398
524
|
fig = ax.get_figure()
|
|
399
525
|
if fig is None:
|
|
@@ -428,7 +554,7 @@ def annotate_last_values(
|
|
|
428
554
|
trans = ax.transData
|
|
429
555
|
|
|
430
556
|
sample = ax.text( # type: ignore[reportUnknownMemberType]
|
|
431
|
-
0, 0, "0"
|
|
557
|
+
0, 0, "0", fontsize=fontsize
|
|
432
558
|
)
|
|
433
559
|
text_height = (
|
|
434
560
|
sample.get_window_extent(renderer).height # type: ignore[reportUnknownArgumentType]
|
|
@@ -459,13 +585,18 @@ def annotate_last_values(
|
|
|
459
585
|
color=color,
|
|
460
586
|
va="center",
|
|
461
587
|
ha="left",
|
|
588
|
+
fontweight=weight if weight is not None else "normal",
|
|
589
|
+
fontsize=fontsize,
|
|
462
590
|
)
|
|
463
591
|
width = t.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
|
|
464
592
|
max_label_width = max(max_label_width, width)
|
|
465
593
|
|
|
594
|
+
# labels spill right of the data, so widen the x-limit by the widest
|
|
595
|
+
# label plus right_pad_px to keep them unclipped (and, with a larger
|
|
596
|
+
# pad, clear of the right-hand y-tick labels).
|
|
466
597
|
inv = trans.inverted()
|
|
467
598
|
x0: float = inv.transform((0, 0))[0] # type: ignore[reportUnknownArgumentType]
|
|
468
|
-
x1: float = inv.transform((max_label_width +
|
|
599
|
+
x1: float = inv.transform((max_label_width + right_pad_px, 0))[0] # type: ignore[reportUnknownArgumentType]
|
|
469
600
|
xmin, xmax = ax.get_xlim()
|
|
470
601
|
ax.set_xlim(xmin, xmax + (x1 - x0))
|
|
471
602
|
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/line_plot.py
RENAMED
|
@@ -29,7 +29,12 @@ from tesorotools.artists._common import (
|
|
|
29
29
|
adjust_figure_for_plot_size,
|
|
30
30
|
annotate_last_values,
|
|
31
31
|
auto_ncol,
|
|
32
|
+
compact_date_axis,
|
|
33
|
+
compact_figsize,
|
|
34
|
+
compact_font_pt,
|
|
35
|
+
declutter_spines,
|
|
32
36
|
draw_vlines,
|
|
37
|
+
legend_above,
|
|
33
38
|
style_baseline,
|
|
34
39
|
style_spines,
|
|
35
40
|
)
|
|
@@ -60,6 +65,12 @@ class LinePlot:
|
|
|
60
65
|
(color, linestyle, ...).
|
|
61
66
|
* ``baseline`` -- draw an axhline at 0 (or 100 when
|
|
62
67
|
``base_100``).
|
|
68
|
+
* ``compact`` -- render at a fixed physical size (7.4 cm
|
|
69
|
+
wide *including* the legend, from ``plots.yaml``) for
|
|
70
|
+
placing two charts per row inside a Word table. Moves
|
|
71
|
+
the legend above the plot expanded to its width, uses a
|
|
72
|
+
``ConciseDateFormatter`` date axis and bold end-of-series
|
|
73
|
+
labels.
|
|
63
74
|
|
|
64
75
|
Render lifecycle: ``build()`` returns ``(Figure, Axes)``
|
|
65
76
|
in memory; ``save(fig)`` persists to ``out_path``;
|
|
@@ -87,6 +98,7 @@ class LinePlot:
|
|
|
87
98
|
series_styles: dict[str, dict[str, Any]] | None = None,
|
|
88
99
|
plot_size: tuple[float, float] | None = None,
|
|
89
100
|
vlines: list[dict[str, Any]] | None = None,
|
|
101
|
+
compact: bool = False,
|
|
90
102
|
) -> None:
|
|
91
103
|
if out_path.suffix != ".png":
|
|
92
104
|
raise ValueError(f"out_path must be .png: {out_path}")
|
|
@@ -108,6 +120,7 @@ class LinePlot:
|
|
|
108
120
|
self.series_styles = series_styles or {}
|
|
109
121
|
self.plot_size = plot_size
|
|
110
122
|
self.vlines = vlines or []
|
|
123
|
+
self.compact = compact
|
|
111
124
|
|
|
112
125
|
@classmethod
|
|
113
126
|
def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
|
|
@@ -130,7 +143,7 @@ class LinePlot:
|
|
|
130
143
|
``base_100_date``, ``annotate``, ``annotate_color``,
|
|
131
144
|
``baseline``, ``fmt`` (``!format``), ``legend``
|
|
132
145
|
(``!legend``), ``figsize``, ``series_styles``,
|
|
133
|
-
``plot_size``, ``vlines``.
|
|
146
|
+
``plot_size``, ``vlines``, ``compact``.
|
|
134
147
|
|
|
135
148
|
Example
|
|
136
149
|
-------
|
|
@@ -199,13 +212,30 @@ class LinePlot:
|
|
|
199
212
|
fig_kw = dict(FIG_CONFIG)
|
|
200
213
|
if self.figsize is not None:
|
|
201
214
|
fig_kw["figsize"] = self.figsize
|
|
215
|
+
elif self.compact:
|
|
216
|
+
fig_kw["figsize"] = compact_figsize()
|
|
202
217
|
fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
|
|
203
218
|
**fig_kw
|
|
204
219
|
)
|
|
220
|
+
font_pt = compact_font_pt() if self.compact else None
|
|
221
|
+
|
|
205
222
|
ax = fig.add_subplot()
|
|
206
223
|
for col in plot_data.columns:
|
|
207
224
|
style = self.series_styles.get(col, {})
|
|
208
|
-
|
|
225
|
+
# compact mode needs matplotlib's native date axis so the
|
|
226
|
+
# ConciseDateFormatter takes effect (x_compat disables pandas'
|
|
227
|
+
# own date plotting machinery).
|
|
228
|
+
plot_data[col].plot(
|
|
229
|
+
ax=ax,
|
|
230
|
+
label=self.series[col],
|
|
231
|
+
x_compat=self.compact,
|
|
232
|
+
**style,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
if self.compact:
|
|
236
|
+
# no left/right padding: the line starts exactly at the first
|
|
237
|
+
# data point. annotate_last_values reopens room on the right.
|
|
238
|
+
ax.margins(x=0)
|
|
209
239
|
|
|
210
240
|
if self.vlines:
|
|
211
241
|
draw_vlines(ax, self.vlines)
|
|
@@ -219,6 +249,18 @@ class LinePlot:
|
|
|
219
249
|
labels=self.series,
|
|
220
250
|
series_styles=self.series_styles,
|
|
221
251
|
annotate_color=self.annotate_color,
|
|
252
|
+
weight="bold" if self.compact else None,
|
|
253
|
+
fontsize=font_pt,
|
|
254
|
+
# blank space (dynamic, scales with font + dpi) opened past
|
|
255
|
+
# the label. compact has the y-axis on the LEFT, so half an em
|
|
256
|
+
# of clearance is enough; normal keeps the y-axis on the RIGHT,
|
|
257
|
+
# so leave a wider gap to separate the value labels from the
|
|
258
|
+
# y-tick numbers they would otherwise butt against.
|
|
259
|
+
right_pad_px=(
|
|
260
|
+
0.5 * (font_pt or 12.0) * fig.dpi / 72.0
|
|
261
|
+
if self.compact
|
|
262
|
+
else 1.6 * plt.rcParams["font.size"] * fig.dpi / 72.0
|
|
263
|
+
),
|
|
222
264
|
)
|
|
223
265
|
|
|
224
266
|
style_spines(
|
|
@@ -231,23 +273,54 @@ class LinePlot:
|
|
|
231
273
|
reference = 100 if self.base_100 else 0
|
|
232
274
|
style_baseline(ax, reference, **AX_CONFIG["baseline"])
|
|
233
275
|
|
|
276
|
+
if self.compact:
|
|
277
|
+
# pin the date ticks to the data span (after annotate widened the
|
|
278
|
+
# x-limit) so the last tick sits next to the final data point and
|
|
279
|
+
# none float in the value-label margin; match Word's body font.
|
|
280
|
+
compact_date_axis(ax, plot_data.index.min(), plot_data.index.max())
|
|
281
|
+
# compact only: y-numbers on the LEFT so the right edge is free
|
|
282
|
+
# for the value labels, and drop the now-tickless top/right
|
|
283
|
+
# spines. Normal mode keeps the house right-hand y-axis and box.
|
|
284
|
+
ax.yaxis.tick_left()
|
|
285
|
+
declutter_spines(ax)
|
|
286
|
+
if font_pt is not None:
|
|
287
|
+
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
288
|
+
axis="both", labelsize=font_pt
|
|
289
|
+
)
|
|
290
|
+
|
|
234
291
|
if self.legend is not None:
|
|
235
292
|
labels = [self.series[c] for c in plot_data.columns]
|
|
236
293
|
handles, label_strs = ax.get_legend_handles_labels()
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
294
|
+
if self.compact:
|
|
295
|
+
# legend ABOVE the plot, expanded to the plot width. ncol is
|
|
296
|
+
# auto-fitted to the (narrow) axes width so labels never
|
|
297
|
+
# overlap; long labels stack into rows. Override the column
|
|
298
|
+
# count via !legend {ncol: N} when you know they fit.
|
|
299
|
+
ncol = (
|
|
300
|
+
self.legend.ncol
|
|
301
|
+
if self.legend.ncol is not None
|
|
302
|
+
else auto_ncol(ax, labels)
|
|
303
|
+
)
|
|
304
|
+
legend_above(
|
|
305
|
+
ax, handles, label_strs, ncol=ncol, fontsize=font_pt
|
|
306
|
+
)
|
|
307
|
+
else:
|
|
308
|
+
fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
|
|
309
|
+
ncol = (
|
|
310
|
+
self.legend.ncol
|
|
311
|
+
if self.legend.ncol is not None
|
|
312
|
+
else auto_ncol(ax, labels, available_width_px=fig_width_px)
|
|
313
|
+
)
|
|
314
|
+
fig.legend( # type: ignore[reportUnknownMemberType]
|
|
315
|
+
handles,
|
|
316
|
+
label_strs,
|
|
317
|
+
loc="outside lower center",
|
|
318
|
+
ncol=ncol,
|
|
319
|
+
)
|
|
249
320
|
|
|
250
|
-
|
|
321
|
+
# compact mode fixes the *figure* size (width includes the legend),
|
|
322
|
+
# so the axes-sizing pass would fight it; skip it there.
|
|
323
|
+
if self.plot_size is not None and not self.compact:
|
|
251
324
|
adjust_figure_for_plot_size(fig, ax, self.plot_size)
|
|
252
325
|
|
|
253
326
|
return fig, ax
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""Waterfall (bridge) chart artist.
|
|
2
|
+
|
|
3
|
+
Defines :class:`Waterfall`, registered as ``!waterfall``. A
|
|
4
|
+
waterfall shows how an initial absolute total is bridged to a
|
|
5
|
+
final absolute total through a sequence of signed
|
|
6
|
+
contributions: the first and last bars rise from zero
|
|
7
|
+
(``start_color`` / ``end_color``), the intermediate bars
|
|
8
|
+
float between running cumulatives (``increase_color`` /
|
|
9
|
+
``decrease_color``).
|
|
10
|
+
|
|
11
|
+
Like :class:`LinePlot` it exposes ``build()`` / ``save()`` /
|
|
12
|
+
``plot()`` and honours ``compact`` for charts that go two per
|
|
13
|
+
row inside a Word table, reusing the shared compact helpers in
|
|
14
|
+
:mod:`tesorotools.artists._common`.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Any, Self
|
|
21
|
+
|
|
22
|
+
import matplotlib.pyplot as plt
|
|
23
|
+
import numpy as np
|
|
24
|
+
import pandas as pd
|
|
25
|
+
from matplotlib.axes import Axes
|
|
26
|
+
from matplotlib.colors import to_rgba
|
|
27
|
+
from matplotlib.figure import Figure
|
|
28
|
+
from yaml.nodes import MappingNode
|
|
29
|
+
|
|
30
|
+
from tesorotools.artists._common import (
|
|
31
|
+
AX_CONFIG,
|
|
32
|
+
FIG_CONFIG,
|
|
33
|
+
Format,
|
|
34
|
+
adjust_figure_for_plot_size,
|
|
35
|
+
compact_figsize,
|
|
36
|
+
compact_font_pt,
|
|
37
|
+
declutter_spines,
|
|
38
|
+
resolve_data,
|
|
39
|
+
style_spines,
|
|
40
|
+
)
|
|
41
|
+
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
42
|
+
from tesorotools.utils.template import TemplateLoader
|
|
43
|
+
|
|
44
|
+
__all__ = ["Waterfall"]
|
|
45
|
+
|
|
46
|
+
WATERFALL_CONFIG: dict[str, Any] = PLOT_CONFIG.get("waterfall", {})
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Waterfall:
|
|
50
|
+
"""Waterfall / bridge chart with the tesorotools visual style.
|
|
51
|
+
|
|
52
|
+
The input is a single column of values indexed by category
|
|
53
|
+
label (e.g. ``1-1-25``, ``Consumo``, ..., ``19-6-26``).
|
|
54
|
+
Entries listed in ``totals`` are drawn as absolute bars
|
|
55
|
+
from zero; the rest are signed deltas that float on the
|
|
56
|
+
running cumulative. ``totals`` defaults to the first and
|
|
57
|
+
last categories (opening and closing balance).
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(
|
|
61
|
+
self,
|
|
62
|
+
out_path: Path,
|
|
63
|
+
*,
|
|
64
|
+
data: pd.DataFrame | None = None,
|
|
65
|
+
data_path: Path | None = None,
|
|
66
|
+
value_col: str | None = None,
|
|
67
|
+
labels: dict[str, str] | None = None,
|
|
68
|
+
totals: list[str] | None = None,
|
|
69
|
+
scale: float = 1,
|
|
70
|
+
annotate: bool = True,
|
|
71
|
+
fmt: Format | None = None,
|
|
72
|
+
figsize: tuple[float, float] | None = None,
|
|
73
|
+
plot_size: tuple[float, float] | None = None,
|
|
74
|
+
compact: bool = False,
|
|
75
|
+
bar_width: float | None = None,
|
|
76
|
+
connectors: bool = False,
|
|
77
|
+
x_rotation: float = 45,
|
|
78
|
+
) -> None:
|
|
79
|
+
if out_path.suffix != ".png":
|
|
80
|
+
raise ValueError(f"out_path must be .png: {out_path}")
|
|
81
|
+
self.out_path = out_path
|
|
82
|
+
self.data = resolve_data(data, data_path)
|
|
83
|
+
self.value_col = value_col
|
|
84
|
+
self.labels = labels or {}
|
|
85
|
+
self.totals = totals
|
|
86
|
+
self.scale = scale
|
|
87
|
+
self.annotate = annotate
|
|
88
|
+
self.fmt = fmt or Format()
|
|
89
|
+
self.figsize = figsize
|
|
90
|
+
self.plot_size = plot_size
|
|
91
|
+
self.compact = compact
|
|
92
|
+
self.bar_width = (
|
|
93
|
+
bar_width
|
|
94
|
+
if bar_width is not None
|
|
95
|
+
else float(WATERFALL_CONFIG.get("bar_width", 0.8))
|
|
96
|
+
)
|
|
97
|
+
self.connectors = connectors
|
|
98
|
+
self.x_rotation = x_rotation
|
|
99
|
+
|
|
100
|
+
@classmethod
|
|
101
|
+
def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
|
|
102
|
+
"""Build a :class:`Waterfall` from the ``!waterfall`` YAML tag.
|
|
103
|
+
|
|
104
|
+
Required keys
|
|
105
|
+
-------------
|
|
106
|
+
``out_path``
|
|
107
|
+
``.png`` destination path.
|
|
108
|
+
``data_path``
|
|
109
|
+
``.feather`` file with a category-indexed DataFrame;
|
|
110
|
+
the value column is ``value_col`` or the first column.
|
|
111
|
+
|
|
112
|
+
Optional keys (forwarded to ``__init__``)
|
|
113
|
+
-----------------------------------------
|
|
114
|
+
``value_col``, ``labels`` (``id -> display label``),
|
|
115
|
+
``totals`` (category labels drawn from zero; default
|
|
116
|
+
first & last), ``scale``, ``annotate``, ``fmt``
|
|
117
|
+
(``!format``), ``figsize``, ``plot_size``, ``compact``,
|
|
118
|
+
``bar_width``, ``connectors``, ``x_rotation``.
|
|
119
|
+
|
|
120
|
+
Example
|
|
121
|
+
-------
|
|
122
|
+
.. code-block:: yaml
|
|
123
|
+
|
|
124
|
+
cartera: !waterfall
|
|
125
|
+
out_path: out/cartera.png
|
|
126
|
+
data_path: data/cartera.feather
|
|
127
|
+
fmt: !format {decimals: 0}
|
|
128
|
+
compact: true
|
|
129
|
+
"""
|
|
130
|
+
cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
|
|
131
|
+
node, deep=True
|
|
132
|
+
)
|
|
133
|
+
cfg.pop("id")
|
|
134
|
+
cfg["out_path"] = Path(cfg["out_path"])
|
|
135
|
+
if "data_path" in cfg:
|
|
136
|
+
cfg["data_path"] = Path(cfg["data_path"])
|
|
137
|
+
return cls(**cfg)
|
|
138
|
+
|
|
139
|
+
def build(self) -> tuple[Figure, Axes]:
|
|
140
|
+
"""Render the waterfall in memory and return ``(Figure, Axes)``."""
|
|
141
|
+
col = (
|
|
142
|
+
self.value_col
|
|
143
|
+
if self.value_col is not None
|
|
144
|
+
else (self.data.columns[0])
|
|
145
|
+
)
|
|
146
|
+
values = self.data[col].to_numpy(dtype=np.float64) * self.scale
|
|
147
|
+
cats = [str(idx) for idx in self.data.index]
|
|
148
|
+
display = [self.labels.get(c, c) for c in cats]
|
|
149
|
+
n = len(values)
|
|
150
|
+
|
|
151
|
+
totals = (
|
|
152
|
+
set(self.totals) if self.totals is not None else {cats[0], cats[-1]}
|
|
153
|
+
)
|
|
154
|
+
is_total = [c in totals for c in cats]
|
|
155
|
+
|
|
156
|
+
# bottoms/tops of each bar plus the cumulative *after* each bar (the
|
|
157
|
+
# height the connector line should hop from).
|
|
158
|
+
bottoms = np.zeros(n)
|
|
159
|
+
tops = np.zeros(n)
|
|
160
|
+
running_after = np.zeros(n)
|
|
161
|
+
running = 0.0
|
|
162
|
+
for i in range(n):
|
|
163
|
+
if is_total[i]:
|
|
164
|
+
bottoms[i], tops[i] = 0.0, values[i]
|
|
165
|
+
running = values[i]
|
|
166
|
+
else:
|
|
167
|
+
start, end = running, running + values[i]
|
|
168
|
+
bottoms[i], tops[i] = min(start, end), max(start, end)
|
|
169
|
+
running = end
|
|
170
|
+
running_after[i] = running
|
|
171
|
+
heights = tops - bottoms
|
|
172
|
+
|
|
173
|
+
# Totals use full-opacity cycle colours; the floating deltas use cycle
|
|
174
|
+
# colours faded by light_alpha (a light tint of the same palette).
|
|
175
|
+
start_color = WATERFALL_CONFIG.get("start_color", "C1")
|
|
176
|
+
end_color = WATERFALL_CONFIG.get("end_color", "C0")
|
|
177
|
+
inc_color = WATERFALL_CONFIG.get("increase_color", "C0")
|
|
178
|
+
dec_color = WATERFALL_CONFIG.get("decrease_color", "C9")
|
|
179
|
+
light_alpha = float(WATERFALL_CONFIG.get("light_alpha", 0.35))
|
|
180
|
+
colors: list[Any] = []
|
|
181
|
+
for i in range(n):
|
|
182
|
+
if i == 0:
|
|
183
|
+
colors.append(to_rgba(start_color))
|
|
184
|
+
elif i == n - 1 or is_total[i]:
|
|
185
|
+
colors.append(to_rgba(end_color))
|
|
186
|
+
else:
|
|
187
|
+
base = inc_color if values[i] >= 0 else dec_color
|
|
188
|
+
colors.append(to_rgba(base, light_alpha))
|
|
189
|
+
|
|
190
|
+
font_pt = compact_font_pt() if self.compact else None
|
|
191
|
+
|
|
192
|
+
fig_kw = dict(FIG_CONFIG)
|
|
193
|
+
if self.figsize is not None:
|
|
194
|
+
fig_kw["figsize"] = self.figsize
|
|
195
|
+
elif self.compact:
|
|
196
|
+
fig_kw["figsize"] = compact_figsize()
|
|
197
|
+
fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
|
|
198
|
+
**fig_kw
|
|
199
|
+
)
|
|
200
|
+
ax: Axes = fig.add_subplot()
|
|
201
|
+
|
|
202
|
+
x = np.arange(n)
|
|
203
|
+
ax.bar( # type: ignore[reportUnknownMemberType]
|
|
204
|
+
x, heights, bottom=bottoms, width=self.bar_width, color=colors
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
if self.connectors:
|
|
208
|
+
conn = WATERFALL_CONFIG.get("connector_color", "#bcbcbc")
|
|
209
|
+
for i in range(n - 1):
|
|
210
|
+
ax.plot( # type: ignore[reportUnknownMemberType]
|
|
211
|
+
[i + self.bar_width / 2, i + 1 - self.bar_width / 2],
|
|
212
|
+
[running_after[i], running_after[i]],
|
|
213
|
+
color=conn,
|
|
214
|
+
linewidth=0.8,
|
|
215
|
+
zorder=0,
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
if self.annotate:
|
|
219
|
+
# at 7.4 cm the per-delta labels collide horizontally, so compact
|
|
220
|
+
# charts label only the opening/closing totals (the bars and the
|
|
221
|
+
# y-axis already convey the steps).
|
|
222
|
+
self._annotate_bars(
|
|
223
|
+
ax,
|
|
224
|
+
x,
|
|
225
|
+
tops,
|
|
226
|
+
values,
|
|
227
|
+
is_total,
|
|
228
|
+
font_pt,
|
|
229
|
+
only_totals=self.compact,
|
|
230
|
+
)
|
|
231
|
+
|
|
232
|
+
style_spines(
|
|
233
|
+
ax,
|
|
234
|
+
decimals=self.fmt.decimals,
|
|
235
|
+
units=self.fmt.units,
|
|
236
|
+
**AX_CONFIG["spines"],
|
|
237
|
+
)
|
|
238
|
+
# y-numbers on the left, drop the tickless top/right spines (bar
|
|
239
|
+
# charts read cleaner without the full box).
|
|
240
|
+
ax.yaxis.tick_left()
|
|
241
|
+
declutter_spines(ax)
|
|
242
|
+
|
|
243
|
+
ax.set_xticks(x) # type: ignore[reportUnknownMemberType]
|
|
244
|
+
ax.set_xticklabels( # type: ignore[reportUnknownMemberType]
|
|
245
|
+
display,
|
|
246
|
+
rotation=self.x_rotation,
|
|
247
|
+
ha="right" if self.x_rotation else "center",
|
|
248
|
+
fontsize=font_pt,
|
|
249
|
+
)
|
|
250
|
+
if font_pt is not None:
|
|
251
|
+
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
252
|
+
axis="y", labelsize=font_pt
|
|
253
|
+
)
|
|
254
|
+
|
|
255
|
+
# headroom on top so the value labels are not clipped.
|
|
256
|
+
headroom = float(WATERFALL_CONFIG.get("headroom", 0.12))
|
|
257
|
+
bottom = float(min(0.0, bottoms.min()))
|
|
258
|
+
top = float(tops.max())
|
|
259
|
+
ax.set_ylim(bottom, top + (top - bottom) * headroom)
|
|
260
|
+
|
|
261
|
+
if self.plot_size is not None and not self.compact:
|
|
262
|
+
adjust_figure_for_plot_size(fig, ax, self.plot_size)
|
|
263
|
+
|
|
264
|
+
return fig, ax
|
|
265
|
+
|
|
266
|
+
def _annotate_bars(
|
|
267
|
+
self,
|
|
268
|
+
ax: Axes,
|
|
269
|
+
x: np.ndarray[tuple[int], np.dtype[np.intp]],
|
|
270
|
+
tops: np.ndarray[tuple[int], np.dtype[np.float64]],
|
|
271
|
+
values: np.ndarray[tuple[int], np.dtype[np.float64]],
|
|
272
|
+
is_total: list[bool],
|
|
273
|
+
font_pt: float | None,
|
|
274
|
+
*,
|
|
275
|
+
only_totals: bool = False,
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Write each bar's value just above its top (totals in bold).
|
|
278
|
+
|
|
279
|
+
With ``only_totals`` set, the floating deltas are left
|
|
280
|
+
unlabelled -- used by compact charts where the per-delta
|
|
281
|
+
labels would overlap.
|
|
282
|
+
"""
|
|
283
|
+
for i in range(len(x)):
|
|
284
|
+
if only_totals and not is_total[i]:
|
|
285
|
+
continue
|
|
286
|
+
text = format_annotation(
|
|
287
|
+
float(values[i]), self.fmt.decimals, self.fmt.units
|
|
288
|
+
)
|
|
289
|
+
ax.annotate( # type: ignore[reportUnknownMemberType]
|
|
290
|
+
text,
|
|
291
|
+
xy=(float(x[i]), float(tops[i])),
|
|
292
|
+
xytext=(0, 3),
|
|
293
|
+
textcoords="offset points",
|
|
294
|
+
ha="center",
|
|
295
|
+
va="bottom",
|
|
296
|
+
fontsize=font_pt,
|
|
297
|
+
fontweight="bold" if is_total[i] else "normal",
|
|
298
|
+
)
|
|
299
|
+
|
|
300
|
+
def save(
|
|
301
|
+
self,
|
|
302
|
+
fig: Figure,
|
|
303
|
+
*,
|
|
304
|
+
path: Path | None = None,
|
|
305
|
+
dpi: int | None = None,
|
|
306
|
+
) -> Path:
|
|
307
|
+
"""Persist *fig* as a PNG and return the path written."""
|
|
308
|
+
target: Path = path if path is not None else self.out_path
|
|
309
|
+
save_kwargs: dict[str, Any] = {}
|
|
310
|
+
if dpi is not None:
|
|
311
|
+
save_kwargs["dpi"] = dpi
|
|
312
|
+
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
313
|
+
target, **save_kwargs
|
|
314
|
+
)
|
|
315
|
+
return target
|
|
316
|
+
|
|
317
|
+
def plot(self) -> Axes:
|
|
318
|
+
"""Build the chart and persist it to ``self.out_path``."""
|
|
319
|
+
fig, ax = self.build()
|
|
320
|
+
self.save(fig)
|
|
321
|
+
plt.close(fig)
|
|
322
|
+
return ax
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# general plot config file
|
|
2
|
+
|
|
3
|
+
style:
|
|
4
|
+
font: Cabinet Grotesk
|
|
5
|
+
|
|
6
|
+
figure:
|
|
7
|
+
dpi: 500
|
|
8
|
+
layout: constrained
|
|
9
|
+
|
|
10
|
+
ax:
|
|
11
|
+
spines:
|
|
12
|
+
color: gray
|
|
13
|
+
linewidth: 1
|
|
14
|
+
baseline:
|
|
15
|
+
color: "#d9e1fc"
|
|
16
|
+
linestyle: solid
|
|
17
|
+
linewidth: 1
|
|
18
|
+
zorder: 1
|
|
19
|
+
|
|
20
|
+
type_curve:
|
|
21
|
+
last:
|
|
22
|
+
alpha: 0.2
|
|
23
|
+
color: C1
|
|
24
|
+
current:
|
|
25
|
+
alpha: 0.3
|
|
26
|
+
color: C0
|
|
27
|
+
line:
|
|
28
|
+
linewidth: 2
|
|
29
|
+
marker: D
|
|
30
|
+
color: C0
|
|
31
|
+
|
|
32
|
+
vector:
|
|
33
|
+
linewidth: 2
|
|
34
|
+
arrowstyle: "-|>"
|
|
35
|
+
mutation_scale: 14 # arrow-head size in points
|
|
36
|
+
label_pad: 5 # gap (points) between arrow head and its label
|
|
37
|
+
margin: 0.15 # fraction of the data range left as padding
|
|
38
|
+
center_line:
|
|
39
|
+
color: gray
|
|
40
|
+
linestyle: dashed
|
|
41
|
+
linewidth: 1
|
|
42
|
+
zorder: 1
|
|
43
|
+
|
|
44
|
+
matrix:
|
|
45
|
+
cmap: Blues
|
|
46
|
+
gap: 0.04 # white gutter between cells (fraction of a cell)
|
|
47
|
+
value_fontsize: 26
|
|
48
|
+
label_fontsize: 13
|
|
49
|
+
value_weight: bold
|
|
50
|
+
light_text: white # value/label colour on dark cells
|
|
51
|
+
dark_text: "#1a1a1a" # value/label colour on light cells
|
|
52
|
+
luminance_threshold: 0.55
|
|
53
|
+
value_offset: 0.08 # value sits this fraction of a cell above centre
|
|
54
|
+
label_offset: 0.14 # label sits this fraction of a cell below centre
|
|
55
|
+
|
|
56
|
+
barh:
|
|
57
|
+
highlight_factor: 0.4
|
|
58
|
+
padding: 5
|
|
59
|
+
|
|
60
|
+
waterfall:
|
|
61
|
+
start_color: C1 # barra inicial (total absoluto), opacidad plena
|
|
62
|
+
end_color: C0 # barra final (total absoluto), opacidad plena
|
|
63
|
+
# Los deltas usan colores DEL CICLO atenuados (menor opacidad): el azul
|
|
64
|
+
# claro de los incrementos es C0 con light_alpha; los decrementos, un rojo
|
|
65
|
+
# del ciclo igualmente atenuado.
|
|
66
|
+
increase_color: C0 # delta positivo (= color de cierre, atenuado)
|
|
67
|
+
decrease_color: C9 # delta negativo (rojo del ciclo, atenuado)
|
|
68
|
+
light_alpha: 0.35 # opacidad de las barras de delta
|
|
69
|
+
connector_color: "#bcbcbc" # líneas de unión entre barras (si connectors=True)
|
|
70
|
+
bar_width: 0.8
|
|
71
|
+
headroom: 0.12 # margen superior para las etiquetas (fracción)
|
|
72
|
+
|
|
73
|
+
line:
|
|
74
|
+
ncol: 5
|
|
75
|
+
|
|
76
|
+
# Modo "compact": gráfico pensado para ir dentro de una tabla del Word,
|
|
77
|
+
# dos por fila, a tamaño físico real (sin que Word lo reduzca). El ancho
|
|
78
|
+
# es TOTAL e INCLUYE la leyenda, que se coloca encima expandida al ancho
|
|
79
|
+
# del gráfico. Las dimensiones se interpretan en centímetros.
|
|
80
|
+
compact:
|
|
81
|
+
width_cm: 7.4 # ancho total de la figura (gráfico + leyenda)
|
|
82
|
+
height_cm: 5.5 # alto total de la figura
|
|
83
|
+
# Tamaño de letra del gráfico EN PUNTOS. Como la figura se renderiza a su
|
|
84
|
+
# tamaño físico real (7,4 cm) y se embebe sin reducir, estos puntos son los
|
|
85
|
+
# mismos que verá el Word: ponlo IGUAL al texto corrido del documento.
|
|
86
|
+
font_pt: 11
|
|
87
|
+
# Leyenda encima, expandida al ancho del gráfico. y0 > 1.0 la separa del eje.
|
|
88
|
+
legend_bbox: [0.0, 1.06, 1.0, 0.12] # x0, y0, ancho, alto (coords de ejes)
|
|
89
|
+
|
|
90
|
+
table:
|
|
91
|
+
style: Light Shading Accent 1
|
|
92
|
+
autofit: False
|
|
93
|
+
block_separator:
|
|
94
|
+
fill: "BFBFBF" # hex color of the inter-block band
|
|
95
|
+
height_twips: 20 # half-band height; total band = 2 * height_twips
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/tesoro.mplstyle
RENAMED
|
@@ -13,8 +13,8 @@ lines.markersize: 8
|
|
|
13
13
|
# grids
|
|
14
14
|
grid.color: d9e1fc
|
|
15
15
|
grid.linestyle: dashed
|
|
16
|
-
grid.linewidth: 0.
|
|
17
|
-
grid.alpha: 0.
|
|
16
|
+
grid.linewidth: 0.8
|
|
17
|
+
grid.alpha: 0.9
|
|
18
18
|
|
|
19
19
|
# color palette
|
|
20
20
|
axes.prop_cycle: cycler('color', ['001e93', 'ffbd4c', '6d6d6d', '000000', 'e6821e', '2957ff', 'fba737', '5da6ff', '00ebb2', '6d0000', '009200', 'df2871', 'd9e1fc', 'ffddab'])
|
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
# general plot config file
|
|
2
|
-
|
|
3
|
-
style:
|
|
4
|
-
font: Cabinet Grotesk
|
|
5
|
-
|
|
6
|
-
figure:
|
|
7
|
-
dpi: 500
|
|
8
|
-
layout: constrained
|
|
9
|
-
|
|
10
|
-
ax:
|
|
11
|
-
spines:
|
|
12
|
-
color: gray
|
|
13
|
-
linewidth: 1
|
|
14
|
-
baseline:
|
|
15
|
-
color: "#d9e1fc"
|
|
16
|
-
linestyle: solid
|
|
17
|
-
linewidth: 1
|
|
18
|
-
zorder: 1
|
|
19
|
-
|
|
20
|
-
type_curve:
|
|
21
|
-
last:
|
|
22
|
-
alpha: 0.2
|
|
23
|
-
color: C1
|
|
24
|
-
current:
|
|
25
|
-
alpha: 0.3
|
|
26
|
-
color: C0
|
|
27
|
-
line:
|
|
28
|
-
linewidth: 2
|
|
29
|
-
marker: D
|
|
30
|
-
color: C0
|
|
31
|
-
|
|
32
|
-
vector:
|
|
33
|
-
linewidth: 2
|
|
34
|
-
arrowstyle: "-|>"
|
|
35
|
-
mutation_scale: 14 # arrow-head size in points
|
|
36
|
-
label_pad: 5 # gap (points) between arrow head and its label
|
|
37
|
-
margin: 0.15 # fraction of the data range left as padding
|
|
38
|
-
center_line:
|
|
39
|
-
color: gray
|
|
40
|
-
linestyle: dashed
|
|
41
|
-
linewidth: 1
|
|
42
|
-
zorder: 1
|
|
43
|
-
|
|
44
|
-
matrix:
|
|
45
|
-
cmap: Blues
|
|
46
|
-
gap: 0.04 # white gutter between cells (fraction of a cell)
|
|
47
|
-
value_fontsize: 26
|
|
48
|
-
label_fontsize: 13
|
|
49
|
-
value_weight: bold
|
|
50
|
-
light_text: white # value/label colour on dark cells
|
|
51
|
-
dark_text: "#1a1a1a" # value/label colour on light cells
|
|
52
|
-
luminance_threshold: 0.55
|
|
53
|
-
value_offset: 0.08 # value sits this fraction of a cell above centre
|
|
54
|
-
label_offset: 0.14 # label sits this fraction of a cell below centre
|
|
55
|
-
|
|
56
|
-
barh:
|
|
57
|
-
highlight_factor: 0.4
|
|
58
|
-
padding: 5
|
|
59
|
-
|
|
60
|
-
line:
|
|
61
|
-
ncol: 5
|
|
62
|
-
|
|
63
|
-
table:
|
|
64
|
-
style: Light Shading Accent 1
|
|
65
|
-
autofit: False
|
|
66
|
-
block_separator:
|
|
67
|
-
fill: "BFBFBF" # hex color of the inter-block band
|
|
68
|
-
height_twips: 20 # half-band height; total band = 2 * height_twips
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/barh_plot.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/type_curve.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/vector_plot.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/README.md
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/data_sources/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/data_sources/debug.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/database/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/node.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/resolution.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/diagnose.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/__init__.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/imf_irfcl.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/__init__.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/content.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/images.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/section.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/subtitle.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/table.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/text.py
RENAMED
|
File without changes
|
{tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/title.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|