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.
Files changed (74) hide show
  1. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/.gitignore +3 -1
  2. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/PKG-INFO +1 -1
  3. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/__init__.py +3 -0
  4. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/__init__.py +2 -0
  5. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/_common.py +136 -5
  6. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/line_plot.py +88 -15
  7. tesorotools_python-0.0.47/src/tesorotools/artists/waterfall.py +322 -0
  8. tesorotools_python-0.0.47/src/tesorotools/assets/plots.yaml +95 -0
  9. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/tesoro.mplstyle +2 -2
  10. tesorotools_python-0.0.46/src/tesorotools/assets/plots.yaml +0 -68
  11. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/pyproject.toml +0 -0
  12. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/_build_context.py +0 -0
  13. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/_registry.py +0 -0
  14. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/barh_plot.py +0 -0
  15. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/matrix.py +0 -0
  16. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/stacked.py +0 -0
  17. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/type_curve.py +0 -0
  18. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/artists/vector_plot.py +0 -0
  19. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/README.md +0 -0
  20. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  21. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  22. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  23. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  24. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  25. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  26. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  27. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  28. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/assets/fonts/README.md +0 -0
  29. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/data_sources/__init__.py +0 -0
  30. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/data_sources/debug.py +0 -0
  31. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/database/__init__.py +0 -0
  32. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/database/local.py +0 -0
  33. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/database/push.py +0 -0
  34. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/database/shared.py +0 -0
  35. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/__init__.py +0 -0
  36. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/node.py +0 -0
  37. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/dependencies/resolution.py +0 -0
  38. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/driver.py +0 -0
  39. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/manifest.py +0 -0
  40. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/offsets/__init__.py +0 -0
  41. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/offsets/offsets.py +0 -0
  42. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/offsets/outliers.py +0 -0
  43. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/orchestration.py +0 -0
  44. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/__init__.py +0 -0
  45. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/diagnose.py +0 -0
  46. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/engine.py +0 -0
  47. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/pipeline/rules.py +0 -0
  48. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/__init__.py +0 -0
  49. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/base.py +0 -0
  50. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/bde.py +0 -0
  51. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/ecb.py +0 -0
  52. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/imf_irfcl.py +0 -0
  53. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/providers/lseg.py +0 -0
  54. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/py.typed +0 -0
  55. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/__init__.py +0 -0
  56. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/__init__.py +0 -0
  57. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/content.py +0 -0
  58. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/images.py +0 -0
  59. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/section.py +0 -0
  60. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/subtitle.py +0 -0
  61. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/table.py +0 -0
  62. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/text.py +0 -0
  63. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/content/title.py +0 -0
  64. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/render/report.py +0 -0
  65. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/testing/__init__.py +0 -0
  66. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/testing/compare.py +0 -0
  67. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/__init__.py +0 -0
  68. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/config.py +0 -0
  69. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/format.py +0 -0
  70. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/globals.py +0 -0
  71. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/matplotlib.py +0 -0
  72. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/series.py +0 -0
  73. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/shortcuts.py +0 -0
  74. {tesorotools_python-0.0.46 → tesorotools_python-0.0.47}/src/tesorotools/utils/template.py +0 -0
@@ -24,4 +24,6 @@ test/
24
24
  .coverage
25
25
 
26
26
  # ruff
27
- .ruff_cache/
27
+ .ruff_cache/
28
+
29
+ demo_compacto/
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: tesorotools-python
3
- Version: 0.0.46
3
+ Version: 0.0.47
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -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.ticker import FuncFormatter
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
- The x-axis limit is extended by the widest label so
396
- the text is not clipped by the axes frame.
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 + 10, 0))[0] # type: ignore[reportUnknownArgumentType]
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
 
@@ -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
- plot_data[col].plot(ax=ax, label=self.series[col], **style)
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
- fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
238
- ncol = (
239
- self.legend.ncol
240
- if self.legend.ncol is not None
241
- else auto_ncol(ax, labels, available_width_px=fig_width_px)
242
- )
243
- fig.legend( # type: ignore[reportUnknownMemberType]
244
- handles,
245
- label_strs,
246
- loc="outside lower center",
247
- ncol=ncol,
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
- if self.plot_size is not None:
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
@@ -13,8 +13,8 @@ lines.markersize: 8
13
13
  # grids
14
14
  grid.color: d9e1fc
15
15
  grid.linestyle: dashed
16
- grid.linewidth: 0.5
17
- grid.alpha: 0.5
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