tesorotools-python 0.0.44__tar.gz → 0.0.46__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.44 → tesorotools_python-0.0.46}/PKG-INFO +4 -1
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/pyproject.toml +1 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/__init__.py +6 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/__init__.py +4 -0
- tesorotools_python-0.0.46/src/tesorotools/artists/matrix.py +302 -0
- tesorotools_python-0.0.46/src/tesorotools/artists/vector_plot.py +315 -0
- tesorotools_python-0.0.46/src/tesorotools/assets/plots.yaml +68 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/__init__.py +11 -4
- tesorotools_python-0.0.46/src/tesorotools/providers/imf_irfcl.py +242 -0
- tesorotools_python-0.0.44/src/tesorotools/assets/plots.yaml +0 -44
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/.gitignore +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/_build_context.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/_registry.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/_common.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/barh_plot.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/line_plot.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/stacked.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/type_curve.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/README.md +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/README.md +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/tesoro.mplstyle +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/data_sources/__init__.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/data_sources/debug.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/database/__init__.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/database/local.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/database/push.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/database/shared.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/dependencies/__init__.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/dependencies/node.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/dependencies/resolution.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/driver.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/manifest.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/offsets/__init__.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/offsets/offsets.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/offsets/outliers.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/orchestration.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/pipeline/__init__.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/pipeline/diagnose.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/pipeline/engine.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/pipeline/rules.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/base.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/bde.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/ecb.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/lseg.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/py.typed +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/__init__.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/__init__.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/content.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/images.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/section.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/subtitle.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/table.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/text.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/title.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/report.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/testing/__init__.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/testing/compare.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/__init__.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/config.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/format.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/globals.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/matplotlib.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/series.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/shortcuts.py +0 -0
- {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/template.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: tesorotools-python
|
|
3
|
-
Version: 0.0.
|
|
3
|
+
Version: 0.0.46
|
|
4
4
|
Requires-Python: >=3.13
|
|
5
5
|
Requires-Dist: babel>=2.17
|
|
6
6
|
Requires-Dist: matplotlib>=3.10
|
|
@@ -16,5 +16,8 @@ Provides-Extra: bde
|
|
|
16
16
|
Requires-Dist: requests>=2.31; extra == 'bde'
|
|
17
17
|
Provides-Extra: ecb
|
|
18
18
|
Requires-Dist: requests>=2.31; extra == 'ecb'
|
|
19
|
+
Provides-Extra: imf
|
|
20
|
+
Requires-Dist: requests>=2.31; extra == 'imf'
|
|
21
|
+
Requires-Dist: truststore>=0.10; extra == 'imf'
|
|
19
22
|
Provides-Extra: lseg
|
|
20
23
|
Requires-Dist: lseg-data>=2.1; extra == 'lseg'
|
|
@@ -49,9 +49,11 @@ from tesorotools.artists import (
|
|
|
49
49
|
HorizontalBarChart,
|
|
50
50
|
Legend,
|
|
51
51
|
LinePlot,
|
|
52
|
+
MatrixChart,
|
|
52
53
|
StackedAreaPlot,
|
|
53
54
|
StackedBarPlot,
|
|
54
55
|
TypeCurve,
|
|
56
|
+
VectorPlot,
|
|
55
57
|
)
|
|
56
58
|
from tesorotools.orchestration import CompositeRegistry, iter_contexts
|
|
57
59
|
from tesorotools.providers.base import (
|
|
@@ -79,6 +81,8 @@ def _register_builtins() -> None:
|
|
|
79
81
|
register_artist("barh", HorizontalBarChart)
|
|
80
82
|
register_artist("grouped_barh", GroupedBarChart)
|
|
81
83
|
register_artist("type_curve", TypeCurve)
|
|
84
|
+
register_artist("vector_plot", VectorPlot)
|
|
85
|
+
register_artist("matrix", MatrixChart)
|
|
82
86
|
|
|
83
87
|
register_tag("format", Format)
|
|
84
88
|
register_tag("legend", Legend)
|
|
@@ -111,6 +115,7 @@ __all__ = [
|
|
|
111
115
|
"Legend",
|
|
112
116
|
"LinePlot",
|
|
113
117
|
"LSEGProvider",
|
|
118
|
+
"MatrixChart",
|
|
114
119
|
"RegistryProtocol",
|
|
115
120
|
"Report",
|
|
116
121
|
"Section",
|
|
@@ -121,6 +126,7 @@ __all__ = [
|
|
|
121
126
|
"Text",
|
|
122
127
|
"Title",
|
|
123
128
|
"TypeCurve",
|
|
129
|
+
"VectorPlot",
|
|
124
130
|
"YamlConstructor",
|
|
125
131
|
"all_artists",
|
|
126
132
|
"all_providers",
|
|
@@ -29,8 +29,10 @@ import matplotlib.style
|
|
|
29
29
|
from tesorotools.artists._common import Artist, Format, Legend
|
|
30
30
|
from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
|
|
31
31
|
from tesorotools.artists.line_plot import LinePlot
|
|
32
|
+
from tesorotools.artists.matrix import MatrixChart
|
|
32
33
|
from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
|
|
33
34
|
from tesorotools.artists.type_curve import TypeCurve
|
|
35
|
+
from tesorotools.artists.vector_plot import VectorPlot
|
|
34
36
|
from tesorotools.utils.globals import STYLE_SHEET
|
|
35
37
|
|
|
36
38
|
matplotlib.style.use(STYLE_SHEET)
|
|
@@ -42,7 +44,9 @@ __all__ = [
|
|
|
42
44
|
"HorizontalBarChart",
|
|
43
45
|
"Legend",
|
|
44
46
|
"LinePlot",
|
|
47
|
+
"MatrixChart",
|
|
45
48
|
"StackedAreaPlot",
|
|
46
49
|
"StackedBarPlot",
|
|
47
50
|
"TypeCurve",
|
|
51
|
+
"VectorPlot",
|
|
48
52
|
]
|
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
"""Annotated matrix / heatmap-table artist.
|
|
2
|
+
|
|
3
|
+
Defines :class:`MatrixChart`, the YAML-tag-aware artist
|
|
4
|
+
registered as ``!matrix``.
|
|
5
|
+
|
|
6
|
+
The chart is a grid of cells -- the "quadrant" or
|
|
7
|
+
segmentation table where each cell is shaded by its value and
|
|
8
|
+
carries a large number plus an optional descriptive label
|
|
9
|
+
(e.g. financial knowledge x digital skill -> "Capacidad
|
|
10
|
+
alta"). Nothing in the implementation is tied to the 2x2
|
|
11
|
+
case: the grid is driven by an ``n_rows x n_cols`` DataFrame,
|
|
12
|
+
so any rectangular layout works (a 3x3 segmentation, a
|
|
13
|
+
5-bucket x 4-bucket transition matrix, ...).
|
|
14
|
+
|
|
15
|
+
Input is a flat ``pd.DataFrame`` indexed by row id with one
|
|
16
|
+
column per column id -- the same wide shape
|
|
17
|
+
:class:`GroupedBarChart` consumes -- so ``rows`` selects and
|
|
18
|
+
labels the index and ``cols`` selects and labels the columns.
|
|
19
|
+
Cell shading uses a sequential colormap normalised over the
|
|
20
|
+
selected values; text colour flips to stay legible on dark
|
|
21
|
+
cells. ``NaN`` cells are left blank (no fill, no text), which
|
|
22
|
+
covers triangular or sparse matrices.
|
|
23
|
+
|
|
24
|
+
Styling constants come from ``PLOT_CONFIG['matrix']``.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Any, Self
|
|
31
|
+
|
|
32
|
+
import matplotlib as mpl
|
|
33
|
+
import matplotlib.pyplot as plt
|
|
34
|
+
import numpy as np
|
|
35
|
+
import pandas as pd
|
|
36
|
+
from matplotlib.axes import Axes
|
|
37
|
+
from matplotlib.colors import Colormap, Normalize
|
|
38
|
+
from matplotlib.figure import Figure
|
|
39
|
+
from matplotlib.patches import Rectangle
|
|
40
|
+
from yaml.nodes import MappingNode
|
|
41
|
+
|
|
42
|
+
from tesorotools.artists._common import FIG_CONFIG, Format, resolve_data
|
|
43
|
+
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
44
|
+
from tesorotools.utils.template import TemplateLoader
|
|
45
|
+
|
|
46
|
+
__all__ = ["MatrixChart"]
|
|
47
|
+
|
|
48
|
+
MATRIX_CONFIG: dict[str, Any] = PLOT_CONFIG["matrix"]
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _text_color(rgba: tuple[float, float, float, float]) -> str:
|
|
52
|
+
"""Pick a legible text colour for a cell filled with *rgba*.
|
|
53
|
+
|
|
54
|
+
Uses relative luminance: dark fills get the light text
|
|
55
|
+
colour, light fills the dark one.
|
|
56
|
+
"""
|
|
57
|
+
r, g, b, _a = rgba
|
|
58
|
+
luminance = 0.299 * r + 0.587 * g + 0.114 * b
|
|
59
|
+
if luminance < MATRIX_CONFIG["luminance_threshold"]:
|
|
60
|
+
return MATRIX_CONFIG["light_text"]
|
|
61
|
+
return MATRIX_CONFIG["dark_text"]
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class MatrixChart:
|
|
65
|
+
"""Annotated heatmap-table artist (any ``n_rows x n_cols``).
|
|
66
|
+
|
|
67
|
+
Parameters
|
|
68
|
+
----------
|
|
69
|
+
out_path
|
|
70
|
+
``.png`` path the rendered chart is saved to.
|
|
71
|
+
data, data_path
|
|
72
|
+
Provide one. A wide DataFrame indexed by row id with
|
|
73
|
+
one column per column id.
|
|
74
|
+
rows
|
|
75
|
+
``id -> label`` for the grid rows, top-to-bottom.
|
|
76
|
+
cols
|
|
77
|
+
``id -> label`` for the grid columns, left-to-right.
|
|
78
|
+
cell_labels
|
|
79
|
+
Optional ``{row_id: {col_id: text}}`` descriptive text
|
|
80
|
+
drawn under each value. Missing entries draw the
|
|
81
|
+
value only.
|
|
82
|
+
scale
|
|
83
|
+
Multiplier applied to the values (e.g. ``100`` to turn
|
|
84
|
+
fractions into percentages).
|
|
85
|
+
fmt
|
|
86
|
+
:class:`Format` for the big per-cell value.
|
|
87
|
+
cmap
|
|
88
|
+
Matplotlib colormap name for the shading (default
|
|
89
|
+
``PLOT_CONFIG['matrix']['cmap']``).
|
|
90
|
+
vmin, vmax
|
|
91
|
+
Value range mapped to the colormap ends. Default to
|
|
92
|
+
the min / max of the selected values.
|
|
93
|
+
xlabel, ylabel
|
|
94
|
+
Axis titles (the column / row variable names).
|
|
95
|
+
figsize
|
|
96
|
+
Override the figure size; defaults to a square so
|
|
97
|
+
cells read as tiles.
|
|
98
|
+
"""
|
|
99
|
+
|
|
100
|
+
def __init__(
|
|
101
|
+
self,
|
|
102
|
+
out_path: Path,
|
|
103
|
+
*,
|
|
104
|
+
data: pd.DataFrame | None = None,
|
|
105
|
+
data_path: Path | None = None,
|
|
106
|
+
rows: dict[str, str],
|
|
107
|
+
cols: dict[str, str],
|
|
108
|
+
cell_labels: dict[str, dict[str, str]] | None = None,
|
|
109
|
+
scale: float = 1,
|
|
110
|
+
fmt: Format | None = None,
|
|
111
|
+
cmap: str | None = None,
|
|
112
|
+
vmin: float | None = None,
|
|
113
|
+
vmax: float | None = None,
|
|
114
|
+
xlabel: str = "",
|
|
115
|
+
ylabel: str = "",
|
|
116
|
+
figsize: tuple[float, float] | None = None,
|
|
117
|
+
) -> None:
|
|
118
|
+
if out_path.suffix != ".png":
|
|
119
|
+
raise ValueError(f"out_path must be .png: {out_path}")
|
|
120
|
+
if not rows or not cols:
|
|
121
|
+
raise ValueError("MatrixChart needs at least one row and column")
|
|
122
|
+
self.out_path = out_path
|
|
123
|
+
self.data = resolve_data(data, data_path)
|
|
124
|
+
self.rows = rows
|
|
125
|
+
self.cols = cols
|
|
126
|
+
self.cell_labels = cell_labels or {}
|
|
127
|
+
self.scale = scale
|
|
128
|
+
self.fmt = fmt or Format()
|
|
129
|
+
self.cmap = cmap or MATRIX_CONFIG["cmap"]
|
|
130
|
+
self.vmin = vmin
|
|
131
|
+
self.vmax = vmax
|
|
132
|
+
self.xlabel = xlabel
|
|
133
|
+
self.ylabel = ylabel
|
|
134
|
+
self.figsize = figsize
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
|
|
138
|
+
"""Build a :class:`MatrixChart` from the ``!matrix`` YAML tag.
|
|
139
|
+
|
|
140
|
+
Required keys
|
|
141
|
+
-------------
|
|
142
|
+
``out_path``
|
|
143
|
+
``.png`` destination path.
|
|
144
|
+
``data_path``
|
|
145
|
+
``.feather`` file with a DataFrame indexed by row
|
|
146
|
+
id and one column per column id.
|
|
147
|
+
``rows`` / ``cols``
|
|
148
|
+
``id -> label`` mappings selecting and ordering the
|
|
149
|
+
grid rows (top-to-bottom) and columns
|
|
150
|
+
(left-to-right).
|
|
151
|
+
|
|
152
|
+
Optional keys (forwarded as-is to ``__init__``)
|
|
153
|
+
----------------------------------------------
|
|
154
|
+
``cell_labels`` (``{row_id: {col_id: text}}``),
|
|
155
|
+
``scale``, ``fmt`` (``!format``), ``cmap``, ``vmin`` /
|
|
156
|
+
``vmax``, ``xlabel`` / ``ylabel``, ``figsize``.
|
|
157
|
+
|
|
158
|
+
Example
|
|
159
|
+
-------
|
|
160
|
+
.. code-block:: yaml
|
|
161
|
+
|
|
162
|
+
capacidad: !matrix
|
|
163
|
+
out_path: out/capacidad.png
|
|
164
|
+
data_path: data/capacidad.feather
|
|
165
|
+
xlabel: "Habilidad digital"
|
|
166
|
+
ylabel: "Conocimientos financieros"
|
|
167
|
+
fmt: !format {decimals: 1, units: "%"}
|
|
168
|
+
rows:
|
|
169
|
+
altos: "Altos"
|
|
170
|
+
bajos: "Bajos"
|
|
171
|
+
cols:
|
|
172
|
+
baja: "Baja"
|
|
173
|
+
alta: "Alta"
|
|
174
|
+
cell_labels:
|
|
175
|
+
altos: {baja: "Solo conocimiento financiero", alta: "Capacidad alta"}
|
|
176
|
+
bajos: {baja: "Capacidad baja", alta: "Solo habilidad digital"}
|
|
177
|
+
"""
|
|
178
|
+
cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
|
|
179
|
+
node, deep=True
|
|
180
|
+
)
|
|
181
|
+
cfg.pop("id")
|
|
182
|
+
cfg["out_path"] = Path(cfg["out_path"])
|
|
183
|
+
if "data_path" in cfg:
|
|
184
|
+
cfg["data_path"] = Path(cfg["data_path"])
|
|
185
|
+
return cls(**cfg)
|
|
186
|
+
|
|
187
|
+
def plot(self) -> Axes:
|
|
188
|
+
"""Render the chart and persist it to ``self.out_path``."""
|
|
189
|
+
row_ids = list(self.rows)
|
|
190
|
+
col_ids = list(self.cols)
|
|
191
|
+
missing_rows = [r for r in row_ids if r not in self.data.index]
|
|
192
|
+
missing_cols = [c for c in col_ids if c not in self.data.columns]
|
|
193
|
+
if missing_rows or missing_cols:
|
|
194
|
+
raise KeyError(
|
|
195
|
+
"MatrixChart ids not found in data — "
|
|
196
|
+
f"rows: {missing_rows}, cols: {missing_cols}"
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
matrix = self.data.loc[row_ids, col_ids] * self.scale
|
|
200
|
+
n_rows = len(row_ids)
|
|
201
|
+
|
|
202
|
+
values_arr = matrix.to_numpy(dtype=float)
|
|
203
|
+
valid = values_arr[~np.isnan(values_arr)]
|
|
204
|
+
vmin = self.vmin if self.vmin is not None else float(valid.min())
|
|
205
|
+
vmax = self.vmax if self.vmax is not None else float(valid.max())
|
|
206
|
+
norm = Normalize(vmin=vmin, vmax=vmax)
|
|
207
|
+
cmap: Colormap = mpl.colormaps[self.cmap]
|
|
208
|
+
|
|
209
|
+
fig_kw = dict(FIG_CONFIG)
|
|
210
|
+
fig_kw["figsize"] = self.figsize if self.figsize is not None else (6, 6)
|
|
211
|
+
fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
|
|
212
|
+
**fig_kw
|
|
213
|
+
)
|
|
214
|
+
ax: Axes = fig.add_subplot()
|
|
215
|
+
|
|
216
|
+
gap: float = MATRIX_CONFIG["gap"]
|
|
217
|
+
for i, row_id in enumerate(row_ids):
|
|
218
|
+
# Row 0 sits at the top, so its cell row is the highest y.
|
|
219
|
+
y_row = n_rows - 1 - i
|
|
220
|
+
for j, col_id in enumerate(col_ids):
|
|
221
|
+
value = float(values_arr[i, j])
|
|
222
|
+
if np.isnan(value):
|
|
223
|
+
continue
|
|
224
|
+
rgba: tuple[float, float, float, float] = cmap(norm(value))
|
|
225
|
+
ax.add_patch(
|
|
226
|
+
Rectangle(
|
|
227
|
+
(j + gap / 2, y_row + gap / 2),
|
|
228
|
+
1 - gap,
|
|
229
|
+
1 - gap,
|
|
230
|
+
facecolor=rgba,
|
|
231
|
+
edgecolor="none",
|
|
232
|
+
)
|
|
233
|
+
)
|
|
234
|
+
self._annotate_cell(ax, j + 0.5, y_row + 0.5, value, rgba)
|
|
235
|
+
label = self.cell_labels.get(row_id, {}).get(col_id)
|
|
236
|
+
if label:
|
|
237
|
+
ax.text( # type: ignore[reportUnknownMemberType]
|
|
238
|
+
j + 0.5,
|
|
239
|
+
y_row + 0.5 - MATRIX_CONFIG["label_offset"],
|
|
240
|
+
label,
|
|
241
|
+
ha="center",
|
|
242
|
+
va="center",
|
|
243
|
+
color=_text_color(rgba),
|
|
244
|
+
fontsize=MATRIX_CONFIG["label_fontsize"],
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
self._style_axes(ax, row_ids, col_ids)
|
|
248
|
+
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
249
|
+
self.out_path
|
|
250
|
+
)
|
|
251
|
+
plt.close(fig)
|
|
252
|
+
return ax
|
|
253
|
+
|
|
254
|
+
def _annotate_cell(
|
|
255
|
+
self,
|
|
256
|
+
ax: Axes,
|
|
257
|
+
cx: float,
|
|
258
|
+
cy: float,
|
|
259
|
+
value: float,
|
|
260
|
+
rgba: tuple[float, float, float, float],
|
|
261
|
+
) -> None:
|
|
262
|
+
"""Draw the big formatted value near a cell's centre."""
|
|
263
|
+
ax.text( # type: ignore[reportUnknownMemberType]
|
|
264
|
+
cx,
|
|
265
|
+
cy + MATRIX_CONFIG["value_offset"],
|
|
266
|
+
format_annotation(value, self.fmt.decimals, self.fmt.units),
|
|
267
|
+
ha="center",
|
|
268
|
+
va="center",
|
|
269
|
+
color=_text_color(rgba),
|
|
270
|
+
fontsize=MATRIX_CONFIG["value_fontsize"],
|
|
271
|
+
fontweight=MATRIX_CONFIG["value_weight"],
|
|
272
|
+
)
|
|
273
|
+
|
|
274
|
+
def _style_axes(
|
|
275
|
+
self, ax: Axes, row_ids: list[str], col_ids: list[str]
|
|
276
|
+
) -> None:
|
|
277
|
+
"""Category ticks, axis titles and a frameless look."""
|
|
278
|
+
n_rows, n_cols = len(row_ids), len(col_ids)
|
|
279
|
+
ax.set_xlim(0, n_cols)
|
|
280
|
+
ax.set_ylim(0, n_rows)
|
|
281
|
+
ax.set_aspect("auto")
|
|
282
|
+
|
|
283
|
+
ax.set_xticks([j + 0.5 for j in range(n_cols)]) # type: ignore[reportUnknownMemberType]
|
|
284
|
+
ax.set_xticklabels( # type: ignore[reportUnknownMemberType]
|
|
285
|
+
[self.cols[c] for c in col_ids]
|
|
286
|
+
)
|
|
287
|
+
# Row 0 is drawn at the top, so its tick is the highest y.
|
|
288
|
+
ax.set_yticks([n_rows - 1 - i + 0.5 for i in range(n_rows)]) # type: ignore[reportUnknownMemberType]
|
|
289
|
+
ax.set_yticklabels( # type: ignore[reportUnknownMemberType]
|
|
290
|
+
[self.rows[r] for r in row_ids]
|
|
291
|
+
)
|
|
292
|
+
ax.tick_params( # type: ignore[reportUnknownMemberType]
|
|
293
|
+
axis="both", length=0
|
|
294
|
+
)
|
|
295
|
+
for spine in ax.spines.values():
|
|
296
|
+
spine.set_visible(False)
|
|
297
|
+
ax.set_xlabel( # type: ignore[reportUnknownMemberType]
|
|
298
|
+
self.xlabel
|
|
299
|
+
)
|
|
300
|
+
ax.set_ylabel( # type: ignore[reportUnknownMemberType]
|
|
301
|
+
self.ylabel
|
|
302
|
+
)
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""Vector (arrow) chart artist.
|
|
2
|
+
|
|
3
|
+
Defines :class:`VectorPlot`, the YAML-tag-aware artist
|
|
4
|
+
registered as ``!vector_plot``.
|
|
5
|
+
|
|
6
|
+
Each entity is drawn as an arrow in a 2D plane from a start
|
|
7
|
+
point to an end point, so a single chart shows how *two*
|
|
8
|
+
variables moved between two periods at once. The common case
|
|
9
|
+
-- arrows fanning out from the origin, as in the classic
|
|
10
|
+
"public debt vs. bond yield since 2020" chart -- falls out
|
|
11
|
+
when the data already holds the *change* in each variable and
|
|
12
|
+
no start columns are given: the start defaults to ``(0, 0)``.
|
|
13
|
+
|
|
14
|
+
Unlike the time-series artists, the input DataFrame is indexed
|
|
15
|
+
by entity code (one row per arrow) and the two axes carry two
|
|
16
|
+
different variables, each with its own optional :class:`Format`
|
|
17
|
+
(``x_fmt`` / ``y_fmt``). Computing the period-over-period
|
|
18
|
+
change is the pipeline's job; this artist only renders the
|
|
19
|
+
pre-computed coordinates.
|
|
20
|
+
|
|
21
|
+
Layout and the shared ``Format`` / ``Legend`` config holders
|
|
22
|
+
live in :mod:`tesorotools.artists._common`; arrow styling
|
|
23
|
+
constants come from ``PLOT_CONFIG['vector']``.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
from __future__ import annotations
|
|
27
|
+
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from typing import Any, Self
|
|
30
|
+
|
|
31
|
+
import matplotlib.pyplot as plt
|
|
32
|
+
import pandas as pd
|
|
33
|
+
from matplotlib.axes import Axes
|
|
34
|
+
from matplotlib.figure import Figure
|
|
35
|
+
from matplotlib.ticker import FuncFormatter
|
|
36
|
+
from yaml.nodes import MappingNode
|
|
37
|
+
|
|
38
|
+
from tesorotools.artists._common import (
|
|
39
|
+
AX_CONFIG,
|
|
40
|
+
FIG_CONFIG,
|
|
41
|
+
Format,
|
|
42
|
+
resolve_data,
|
|
43
|
+
)
|
|
44
|
+
from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
|
|
45
|
+
from tesorotools.utils.template import TemplateLoader
|
|
46
|
+
|
|
47
|
+
__all__ = ["VectorPlot"]
|
|
48
|
+
|
|
49
|
+
VECTOR_CONFIG: dict[str, Any] = PLOT_CONFIG["vector"]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
class VectorPlot:
|
|
53
|
+
"""Arrow chart showing the change in two variables.
|
|
54
|
+
|
|
55
|
+
Each entity in ``series`` is rendered as an arrow from a
|
|
56
|
+
start point to an end point. With only ``x`` / ``y``
|
|
57
|
+
given, arrows start at the origin and their tips encode
|
|
58
|
+
the change in each variable (the usual "since date X"
|
|
59
|
+
chart). Provide ``x_start`` / ``y_start`` to draw arrows
|
|
60
|
+
between two arbitrary points instead (period-to-period
|
|
61
|
+
migration in the plane).
|
|
62
|
+
|
|
63
|
+
Parameters
|
|
64
|
+
----------
|
|
65
|
+
out_path
|
|
66
|
+
``.png`` destination path.
|
|
67
|
+
data / data_path
|
|
68
|
+
The source DataFrame (in memory) or a ``.feather``
|
|
69
|
+
file; exactly one. Indexed by entity code, one row
|
|
70
|
+
per arrow.
|
|
71
|
+
series
|
|
72
|
+
``entity_code -> display_label`` mapping. Selects and
|
|
73
|
+
orders the rows drawn; the codes must appear in the
|
|
74
|
+
DataFrame index.
|
|
75
|
+
x, y
|
|
76
|
+
Column names holding the arrow-tip coordinates.
|
|
77
|
+
x_start, y_start
|
|
78
|
+
Optional column names for the arrow tails. When
|
|
79
|
+
omitted the tail sits at ``0`` on that axis.
|
|
80
|
+
x_scale, y_scale
|
|
81
|
+
Per-axis multipliers applied to the raw values (e.g.
|
|
82
|
+
``100`` to turn rates into basis points).
|
|
83
|
+
xlabel, ylabel
|
|
84
|
+
Axis titles.
|
|
85
|
+
x_fmt, y_fmt
|
|
86
|
+
Per-axis :class:`Format` controlling tick decimals and
|
|
87
|
+
units.
|
|
88
|
+
figsize
|
|
89
|
+
Optional ``(width, height)`` in inches.
|
|
90
|
+
series_styles
|
|
91
|
+
Per-entity matplotlib kwargs for the arrow; ``color``
|
|
92
|
+
and ``linestyle`` are honoured (``linestyle: dashed``
|
|
93
|
+
reproduces the dashed arrows used for outliers).
|
|
94
|
+
"""
|
|
95
|
+
|
|
96
|
+
def __init__(
|
|
97
|
+
self,
|
|
98
|
+
out_path: Path,
|
|
99
|
+
*,
|
|
100
|
+
data: pd.DataFrame | None = None,
|
|
101
|
+
data_path: Path | None = None,
|
|
102
|
+
series: dict[str, str],
|
|
103
|
+
x: str,
|
|
104
|
+
y: str,
|
|
105
|
+
x_start: str | None = None,
|
|
106
|
+
y_start: str | None = None,
|
|
107
|
+
x_scale: float = 1,
|
|
108
|
+
y_scale: float = 1,
|
|
109
|
+
xlabel: str = "",
|
|
110
|
+
ylabel: str = "",
|
|
111
|
+
x_fmt: Format | None = None,
|
|
112
|
+
y_fmt: Format | None = None,
|
|
113
|
+
figsize: tuple[float, float] | None = None,
|
|
114
|
+
series_styles: dict[str, dict[str, Any]] | None = None,
|
|
115
|
+
) -> None:
|
|
116
|
+
if out_path.suffix != ".png":
|
|
117
|
+
raise ValueError(f"out_path must be .png: {out_path}")
|
|
118
|
+
if not series:
|
|
119
|
+
raise ValueError("VectorPlot needs at least one entity in `series`")
|
|
120
|
+
self.out_path = out_path
|
|
121
|
+
self.data = resolve_data(data, data_path)
|
|
122
|
+
self.series = series
|
|
123
|
+
self.x = x
|
|
124
|
+
self.y = y
|
|
125
|
+
self.x_start = x_start
|
|
126
|
+
self.y_start = y_start
|
|
127
|
+
self.x_scale = x_scale
|
|
128
|
+
self.y_scale = y_scale
|
|
129
|
+
self.xlabel = xlabel
|
|
130
|
+
self.ylabel = ylabel
|
|
131
|
+
self.x_fmt = x_fmt or Format()
|
|
132
|
+
self.y_fmt = y_fmt or Format()
|
|
133
|
+
self.figsize = figsize
|
|
134
|
+
self.series_styles = series_styles or {}
|
|
135
|
+
|
|
136
|
+
@classmethod
|
|
137
|
+
def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
|
|
138
|
+
"""Build a :class:`VectorPlot` from the ``!vector_plot`` YAML tag.
|
|
139
|
+
|
|
140
|
+
Required keys
|
|
141
|
+
-------------
|
|
142
|
+
``out_path``
|
|
143
|
+
``.png`` destination path.
|
|
144
|
+
``data_path``
|
|
145
|
+
``.feather`` file indexed by entity code, holding
|
|
146
|
+
the arrow-tip columns.
|
|
147
|
+
``series``
|
|
148
|
+
``entity_code -> display_label`` mapping; the codes
|
|
149
|
+
must appear in the DataFrame index.
|
|
150
|
+
``x`` / ``y``
|
|
151
|
+
Column names for the arrow-tip coordinates.
|
|
152
|
+
|
|
153
|
+
Optional keys (forwarded as-is to ``__init__``)
|
|
154
|
+
----------------------------------------------
|
|
155
|
+
``x_start`` / ``y_start``, ``x_scale`` / ``y_scale``,
|
|
156
|
+
``xlabel`` / ``ylabel``, ``x_fmt`` / ``y_fmt``
|
|
157
|
+
(``!format``), ``figsize``, ``series_styles``.
|
|
158
|
+
|
|
159
|
+
Example
|
|
160
|
+
-------
|
|
161
|
+
.. code-block:: yaml
|
|
162
|
+
|
|
163
|
+
debt_vs_yield: !vector_plot
|
|
164
|
+
out_path: out/debt_yield.png
|
|
165
|
+
data_path: data/debt_yield.feather
|
|
166
|
+
x: debt_change
|
|
167
|
+
y: yield_change
|
|
168
|
+
xlabel: "Deuda pública (% PIB), dif. desde ene-2020"
|
|
169
|
+
ylabel: "Var. tir 10A desde ene-2020"
|
|
170
|
+
x_fmt: !format {decimals: 1, units: "%"}
|
|
171
|
+
y_fmt: !format {decimals: 1}
|
|
172
|
+
series:
|
|
173
|
+
ES: "ES"
|
|
174
|
+
DE: "DE"
|
|
175
|
+
IT: "IT"
|
|
176
|
+
series_styles:
|
|
177
|
+
IT: {linestyle: dashed}
|
|
178
|
+
"""
|
|
179
|
+
cfg: dict[str, Any] = loader.construct_mapping( # type: ignore[assignment]
|
|
180
|
+
node, deep=True
|
|
181
|
+
)
|
|
182
|
+
cfg.pop("id")
|
|
183
|
+
cfg["out_path"] = Path(cfg["out_path"])
|
|
184
|
+
if "data_path" in cfg:
|
|
185
|
+
cfg["data_path"] = Path(cfg["data_path"])
|
|
186
|
+
return cls(**cfg)
|
|
187
|
+
|
|
188
|
+
def _coords(self, code: str) -> tuple[float, float, float, float]:
|
|
189
|
+
"""Return the ``(x0, y0, x1, y1)`` arrow for *code*, scaled."""
|
|
190
|
+
row: pd.Series[Any] = self.data.loc[code] # type: ignore[assignment]
|
|
191
|
+
x1 = float(row[self.x]) * self.x_scale
|
|
192
|
+
y1 = float(row[self.y]) * self.y_scale
|
|
193
|
+
x0 = float(row[self.x_start]) * self.x_scale if self.x_start else 0.0
|
|
194
|
+
y0 = float(row[self.y_start]) * self.y_scale if self.y_start else 0.0
|
|
195
|
+
return x0, y0, x1, y1
|
|
196
|
+
|
|
197
|
+
def plot(self) -> Axes:
|
|
198
|
+
needed = [self.x, self.y]
|
|
199
|
+
if self.x_start is not None:
|
|
200
|
+
needed.append(self.x_start)
|
|
201
|
+
if self.y_start is not None:
|
|
202
|
+
needed.append(self.y_start)
|
|
203
|
+
missing = [c for c in needed if c not in self.data.columns]
|
|
204
|
+
if missing:
|
|
205
|
+
raise KeyError(
|
|
206
|
+
f"VectorPlot columns not found in data: {missing}. "
|
|
207
|
+
f"Available: {list(self.data.columns)}"
|
|
208
|
+
)
|
|
209
|
+
unknown = [c for c in self.series if c not in self.data.index]
|
|
210
|
+
if unknown:
|
|
211
|
+
raise KeyError(
|
|
212
|
+
f"VectorPlot series codes not in data index: {unknown}"
|
|
213
|
+
)
|
|
214
|
+
|
|
215
|
+
fig_kw = dict(FIG_CONFIG)
|
|
216
|
+
if self.figsize is not None:
|
|
217
|
+
fig_kw["figsize"] = self.figsize
|
|
218
|
+
fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
|
|
219
|
+
**fig_kw
|
|
220
|
+
)
|
|
221
|
+
ax: Axes = fig.add_subplot()
|
|
222
|
+
|
|
223
|
+
cycle: list[str] = plt.rcParams["axes.prop_cycle"].by_key()["color"]
|
|
224
|
+
xs: list[float] = [0.0]
|
|
225
|
+
ys: list[float] = [0.0]
|
|
226
|
+
for i, code in enumerate(self.series):
|
|
227
|
+
x0, y0, x1, y1 = self._coords(code)
|
|
228
|
+
xs.extend((x0, x1))
|
|
229
|
+
ys.extend((y0, y1))
|
|
230
|
+
style = self.series_styles.get(code, {})
|
|
231
|
+
color: Any = style.get("color", cycle[i % len(cycle)])
|
|
232
|
+
linestyle: Any = style.get("linestyle", "solid")
|
|
233
|
+
ax.annotate( # type: ignore[reportUnknownMemberType]
|
|
234
|
+
"",
|
|
235
|
+
xy=(x1, y1),
|
|
236
|
+
xytext=(x0, y0),
|
|
237
|
+
arrowprops={
|
|
238
|
+
"arrowstyle": VECTOR_CONFIG["arrowstyle"],
|
|
239
|
+
"color": color,
|
|
240
|
+
"linestyle": linestyle,
|
|
241
|
+
"linewidth": VECTOR_CONFIG["linewidth"],
|
|
242
|
+
"mutation_scale": VECTOR_CONFIG["mutation_scale"],
|
|
243
|
+
"shrinkA": 0,
|
|
244
|
+
"shrinkB": 0,
|
|
245
|
+
},
|
|
246
|
+
)
|
|
247
|
+
pad: float = VECTOR_CONFIG["label_pad"]
|
|
248
|
+
ax.annotate( # type: ignore[reportUnknownMemberType]
|
|
249
|
+
self.series[code],
|
|
250
|
+
xy=(x1, y1),
|
|
251
|
+
xytext=(
|
|
252
|
+
pad if x1 >= x0 else -pad,
|
|
253
|
+
pad if y1 >= y0 else -pad,
|
|
254
|
+
),
|
|
255
|
+
textcoords="offset points",
|
|
256
|
+
color=color,
|
|
257
|
+
ha="left" if x1 >= x0 else "right",
|
|
258
|
+
va="bottom" if y1 >= y0 else "top",
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
self._style_axes(ax, xs, ys)
|
|
262
|
+
fig.savefig( # type: ignore[reportUnknownMemberType]
|
|
263
|
+
self.out_path
|
|
264
|
+
)
|
|
265
|
+
plt.close(fig)
|
|
266
|
+
return ax
|
|
267
|
+
|
|
268
|
+
def _style_axes(self, ax: Axes, xs: list[float], ys: list[float]) -> None:
|
|
269
|
+
"""Frame, origin cross-hairs, tick formatters and labels."""
|
|
270
|
+
margin: float = VECTOR_CONFIG["margin"]
|
|
271
|
+
_set_limits(ax.set_xlim, xs, margin)
|
|
272
|
+
_set_limits(ax.set_ylim, ys, margin)
|
|
273
|
+
|
|
274
|
+
center = dict(VECTOR_CONFIG["center_line"])
|
|
275
|
+
ax.axvline( # type: ignore[reportUnknownMemberType]
|
|
276
|
+
x=0, **center
|
|
277
|
+
)
|
|
278
|
+
ax.axhline( # type: ignore[reportUnknownMemberType]
|
|
279
|
+
y=0, **AX_CONFIG["baseline"]
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
spines = AX_CONFIG["spines"]
|
|
283
|
+
for spine in ax.spines.values():
|
|
284
|
+
spine.set_color(spines["color"])
|
|
285
|
+
spine.set_linewidth(spines["linewidth"])
|
|
286
|
+
|
|
287
|
+
def _fmt_x(v: float, _pos: int) -> str:
|
|
288
|
+
return format_annotation(v, self.x_fmt.decimals, self.x_fmt.units)
|
|
289
|
+
|
|
290
|
+
def _fmt_y(v: float, _pos: int) -> str:
|
|
291
|
+
return format_annotation(v, self.y_fmt.decimals, self.y_fmt.units)
|
|
292
|
+
|
|
293
|
+
ax.xaxis.set_major_formatter(FuncFormatter(_fmt_x))
|
|
294
|
+
ax.yaxis.set_major_formatter(FuncFormatter(_fmt_y))
|
|
295
|
+
ax.set_xlabel( # type: ignore[reportUnknownMemberType]
|
|
296
|
+
self.xlabel
|
|
297
|
+
)
|
|
298
|
+
ax.set_ylabel( # type: ignore[reportUnknownMemberType]
|
|
299
|
+
self.ylabel
|
|
300
|
+
)
|
|
301
|
+
for tick in (*ax.get_xticklines(), *ax.get_yticklines()):
|
|
302
|
+
tick.set_markeredgecolor(spines["color"])
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _set_limits(setter: Any, values: list[float], margin: float) -> None:
|
|
306
|
+
"""Set an axis range to span *values* with *margin* padding.
|
|
307
|
+
|
|
308
|
+
The origin is always included (the caller seeds ``values``
|
|
309
|
+
with ``0``). A degenerate span collapses to ``±1`` so the
|
|
310
|
+
arrow stays visible.
|
|
311
|
+
"""
|
|
312
|
+
lo, hi = min(values), max(values)
|
|
313
|
+
span = hi - lo
|
|
314
|
+
pad = span * margin if span > 0 else 1.0
|
|
315
|
+
setter(lo - pad, hi + pad)
|