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.
Files changed (73) hide show
  1. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/PKG-INFO +4 -1
  2. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/pyproject.toml +1 -0
  3. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/__init__.py +6 -0
  4. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/__init__.py +4 -0
  5. tesorotools_python-0.0.46/src/tesorotools/artists/matrix.py +302 -0
  6. tesorotools_python-0.0.46/src/tesorotools/artists/vector_plot.py +315 -0
  7. tesorotools_python-0.0.46/src/tesorotools/assets/plots.yaml +68 -0
  8. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/__init__.py +11 -4
  9. tesorotools_python-0.0.46/src/tesorotools/providers/imf_irfcl.py +242 -0
  10. tesorotools_python-0.0.44/src/tesorotools/assets/plots.yaml +0 -44
  11. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/.gitignore +0 -0
  12. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/_build_context.py +0 -0
  13. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/_registry.py +0 -0
  14. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/_common.py +0 -0
  15. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/barh_plot.py +0 -0
  16. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/line_plot.py +0 -0
  17. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/stacked.py +0 -0
  18. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/artists/type_curve.py +0 -0
  19. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/README.md +0 -0
  20. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  21. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  22. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  23. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  24. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  25. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  26. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  27. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  28. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/fonts/README.md +0 -0
  29. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  30. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/data_sources/__init__.py +0 -0
  31. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/data_sources/debug.py +0 -0
  32. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/database/__init__.py +0 -0
  33. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/database/local.py +0 -0
  34. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/database/push.py +0 -0
  35. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/database/shared.py +0 -0
  36. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/dependencies/__init__.py +0 -0
  37. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/dependencies/node.py +0 -0
  38. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/dependencies/resolution.py +0 -0
  39. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/driver.py +0 -0
  40. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/manifest.py +0 -0
  41. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/offsets/__init__.py +0 -0
  42. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/offsets/offsets.py +0 -0
  43. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/offsets/outliers.py +0 -0
  44. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/orchestration.py +0 -0
  45. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/pipeline/__init__.py +0 -0
  46. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/pipeline/diagnose.py +0 -0
  47. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/pipeline/engine.py +0 -0
  48. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/pipeline/rules.py +0 -0
  49. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/base.py +0 -0
  50. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/bde.py +0 -0
  51. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/ecb.py +0 -0
  52. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/providers/lseg.py +0 -0
  53. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/py.typed +0 -0
  54. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/__init__.py +0 -0
  55. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/__init__.py +0 -0
  56. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/content.py +0 -0
  57. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/images.py +0 -0
  58. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/section.py +0 -0
  59. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/subtitle.py +0 -0
  60. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/table.py +0 -0
  61. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/text.py +0 -0
  62. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/content/title.py +0 -0
  63. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/render/report.py +0 -0
  64. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/testing/__init__.py +0 -0
  65. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/testing/compare.py +0 -0
  66. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/__init__.py +0 -0
  67. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/config.py +0 -0
  68. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/format.py +0 -0
  69. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/globals.py +0 -0
  70. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/matplotlib.py +0 -0
  71. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/series.py +0 -0
  72. {tesorotools_python-0.0.44 → tesorotools_python-0.0.46}/src/tesorotools/utils/shortcuts.py +0 -0
  73. {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.44
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'
@@ -27,6 +27,7 @@ dependencies = [
27
27
  [project.optional-dependencies]
28
28
  bde = ["requests>=2.31"]
29
29
  ecb = ["requests>=2.31"]
30
+ imf = ["requests>=2.31", "truststore>=0.10"]
30
31
  lseg = ["lseg-data>=2.1"]
31
32
 
32
33
  [dependency-groups]
@@ -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)