tesorotools-python 0.0.47__tar.gz → 0.0.48__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.47 → tesorotools_python-0.0.48}/PKG-INFO +1 -1
  2. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/pyproject.toml +13 -0
  3. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/__init__.py +3 -7
  4. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/_common.py +303 -48
  5. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/barh_plot.py +3 -2
  6. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/line_plot.py +102 -23
  7. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/matrix.py +7 -2
  8. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/stacked.py +3 -2
  9. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/type_curve.py +2 -1
  10. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/vector_plot.py +2 -1
  11. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/artists/waterfall.py +9 -5
  12. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/plots.yaml +24 -6
  13. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/matplotlib.py +19 -1
  14. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/.gitignore +0 -0
  15. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/__init__.py +0 -0
  16. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/_build_context.py +0 -0
  17. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/_registry.py +0 -0
  18. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/README.md +0 -0
  19. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  20. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  21. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  22. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  23. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  24. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  25. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  26. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  27. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/fonts/README.md +0 -0
  28. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  29. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/data_sources/__init__.py +0 -0
  30. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/data_sources/debug.py +0 -0
  31. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/database/__init__.py +0 -0
  32. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/database/local.py +0 -0
  33. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/database/push.py +0 -0
  34. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/database/shared.py +0 -0
  35. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/dependencies/__init__.py +0 -0
  36. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/dependencies/node.py +0 -0
  37. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/dependencies/resolution.py +0 -0
  38. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/driver.py +0 -0
  39. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/manifest.py +0 -0
  40. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/offsets/__init__.py +0 -0
  41. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/offsets/offsets.py +0 -0
  42. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/offsets/outliers.py +0 -0
  43. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/orchestration.py +0 -0
  44. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/__init__.py +0 -0
  45. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/diagnose.py +0 -0
  46. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/engine.py +0 -0
  47. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/pipeline/rules.py +0 -0
  48. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/__init__.py +0 -0
  49. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/base.py +0 -0
  50. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/bde.py +0 -0
  51. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/ecb.py +0 -0
  52. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/imf_irfcl.py +0 -0
  53. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/providers/lseg.py +0 -0
  54. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/py.typed +0 -0
  55. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/__init__.py +0 -0
  56. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/__init__.py +0 -0
  57. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/content.py +0 -0
  58. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/images.py +0 -0
  59. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/section.py +0 -0
  60. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/subtitle.py +0 -0
  61. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/table.py +0 -0
  62. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/text.py +0 -0
  63. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/content/title.py +0 -0
  64. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/render/report.py +0 -0
  65. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/testing/__init__.py +0 -0
  66. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/testing/compare.py +0 -0
  67. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/__init__.py +0 -0
  68. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/config.py +0 -0
  69. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/format.py +0 -0
  70. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/globals.py +0 -0
  71. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/series.py +0 -0
  72. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/src/tesorotools/utils/shortcuts.py +0 -0
  73. {tesorotools_python-0.0.47 → tesorotools_python-0.0.48}/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.47
3
+ Version: 0.0.48
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -54,12 +54,25 @@ artifacts = ["src/tesorotools/assets/fonts/*.otf"]
54
54
  include = ["src/tesorotools"]
55
55
  artifacts = ["src/tesorotools/assets/fonts/*.otf"]
56
56
 
57
+ [tool.coverage.run]
58
+ source = ["tesorotools"]
59
+ # Único módulo excluido: el cliente LSEG/Refinitiv es un wrapper de un SDK
60
+ # externo propietario (`import lseg.data`) que necesita sesión y credenciales
61
+ # en vivo; cubrirlo al 100% exigiría mocks frágiles atados a la API del SDK.
62
+ # Todo lo demás (incluida la resolución de dependencias de series derivadas)
63
+ # se cubre con tests reales.
64
+ omit = ["*/providers/lseg.py"]
65
+
57
66
  [tool.coverage.report]
58
67
  exclude_lines = [
68
+ 'pragma: no cover',
59
69
  'if __name__ == "__main__"',
60
70
  '^\s*\.\.\.$',
61
71
  'if TYPE_CHECKING',
72
+ 'raise NotImplementedError',
73
+ '@(abc\.)?abstractmethod',
62
74
  ]
75
+ fail_under = 100
63
76
 
64
77
  [tool.ruff]
65
78
  line-length = 80
@@ -1,7 +1,8 @@
1
1
  """Image artists.
2
2
 
3
- Five chart classes, one shared helpers module, one
4
- matplotlib stylesheet side effect on import.
3
+ Five chart classes and one shared helpers module. Importing
4
+ ``_common`` applies the house style (palette + fonts) as a side
5
+ effect, so the cycle is active no matter which artist is imported.
5
6
 
6
7
  Each class follows the same shape:
7
8
 
@@ -24,8 +25,6 @@ live under :mod:`tesorotools.render`; this package is
24
25
  strictly image output.
25
26
  """
26
27
 
27
- import matplotlib.style
28
-
29
28
  from tesorotools.artists._common import Artist, Format, Legend
30
29
  from tesorotools.artists.barh_plot import GroupedBarChart, HorizontalBarChart
31
30
  from tesorotools.artists.line_plot import LinePlot
@@ -34,9 +33,6 @@ from tesorotools.artists.stacked import StackedAreaPlot, StackedBarPlot
34
33
  from tesorotools.artists.type_curve import TypeCurve
35
34
  from tesorotools.artists.vector_plot import VectorPlot
36
35
  from tesorotools.artists.waterfall import Waterfall
37
- from tesorotools.utils.globals import STYLE_SHEET
38
-
39
- matplotlib.style.use(STYLE_SHEET)
40
36
 
41
37
  __all__ = [
42
38
  "Artist",
@@ -33,28 +33,60 @@ from matplotlib.dates import AutoDateLocator, ConciseDateFormatter
33
33
  from matplotlib.dates import (
34
34
  date2num, # type: ignore[reportUnknownVariableType]
35
35
  )
36
+ from matplotlib.dates import (
37
+ num2date, # type: ignore[reportUnknownVariableType]
38
+ )
36
39
  from matplotlib.figure import Figure
37
40
  from matplotlib.font_manager import FontProperties
38
41
  from matplotlib.legend import Legend as MplLegend
39
- from matplotlib.ticker import FixedLocator, FuncFormatter
42
+ from matplotlib.ticker import FixedLocator, FuncFormatter, MaxNLocator
40
43
  from yaml.nodes import MappingNode
41
44
 
42
45
  from tesorotools.utils.matplotlib import (
43
46
  PLOT_CONFIG,
47
+ apply_house_style,
44
48
  format_annotation,
45
- load_fonts,
46
49
  )
47
50
  from tesorotools.utils.template import TemplateLoader
48
51
 
49
- load_fonts()
52
+ # Apply the full house look (colour cycle + grid + tick sizes from the
53
+ # stylesheet, then the bundled fonts) once, here at import: every image
54
+ # artist imports this module, so the cycle is active no matter which
55
+ # artist (or caller) triggers the import first.
56
+ apply_house_style()
50
57
 
51
58
  AX_CONFIG: dict[str, Any] = PLOT_CONFIG["ax"]
52
59
  FIG_CONFIG: dict[str, Any] = PLOT_CONFIG["figure"]
53
60
  COMPACT_CONFIG: dict[str, Any] = PLOT_CONFIG.get("compact", {})
61
+ SAVE_CONFIG: dict[str, Any] = PLOT_CONFIG.get("save", {})
54
62
 
55
63
  CM_TO_IN: float = 1.0 / 2.54
56
64
 
57
65
 
66
+ def dynamic_dpi(fig: Figure) -> int:
67
+ """Save DPI adapted to the figure's physical size.
68
+
69
+ The figure DPI (``figure.dpi`` in ``plots.yaml``) drives *layout* and
70
+ is deliberately high; saving every PNG at it wastes memory on large
71
+ figures (a 16 cm chart at 500 dpi is ~3150 px wide). Instead the save
72
+ DPI scales **inversely with size** so the long side lands near
73
+ ``save.target_px`` pixels: big figures save at fewer dpi (lighter
74
+ files), small ones at more. It is clamped to ``[save.min_dpi,
75
+ figure.dpi]`` -- never below ``save.min_dpi`` (so the result is always
76
+ print-sharp, never pixelated) and never above the layout dpi (above
77
+ which extra pixels are pure waste).
78
+ """
79
+ target_px: float = float(SAVE_CONFIG.get("target_px", 2000))
80
+ min_dpi: float = float(SAVE_CONFIG.get("min_dpi", 300))
81
+ max_dpi: float = float(FIG_CONFIG.get("dpi", 500))
82
+ width_in, height_in = (float(v) for v in fig.get_size_inches())
83
+ long_in = max(width_in, height_in)
84
+ if long_in <= 0: # pragma: no cover - defensive: a real figure
85
+ # never has a zero/negative side; guards against a 0/0 division.
86
+ return int(max_dpi)
87
+ return int(round(min(max_dpi, max(min_dpi, target_px / long_in))))
88
+
89
+
58
90
  # ----------------------------------------------------------------------
59
91
  # Structural contract for image artists
60
92
  # ----------------------------------------------------------------------
@@ -224,6 +256,11 @@ def auto_ncol(
224
256
  ax: Axes,
225
257
  labels: list[str],
226
258
  available_width_px: float | None = None,
259
+ *,
260
+ fontsize: float | None = None,
261
+ handlelength: float | None = None,
262
+ handletextpad: float | None = None,
263
+ columnspacing: float | None = None,
227
264
  ) -> int:
228
265
  """Choose the maximum legend ``ncol`` that fits the axes.
229
266
 
@@ -236,6 +273,12 @@ def auto_ncol(
236
273
  Pass ``available_width_px`` when the legend will live in
237
274
  ``fig.legend`` (figure-wide space) instead of
238
275
  ``ax.legend``; defaults to the axes width.
276
+
277
+ The keyword overrides (``fontsize`` / ``handlelength`` /
278
+ ``handletextpad`` / ``columnspacing``) let a caller measure
279
+ against a legend styled differently from ``rcParams`` -- the
280
+ compact mode passes its smaller legend font and shorter
281
+ handles here so the column count matches what is drawn.
239
282
  """
240
283
  fig = ax.get_figure()
241
284
  if fig is None:
@@ -245,18 +288,35 @@ def auto_ncol(
245
288
  if available_width_px is None:
246
289
  available_width_px = ax.get_window_extent(renderer).width # type: ignore[reportUnknownArgumentType]
247
290
 
248
- fontsize_pt: float = FontProperties(
249
- size=plt.rcParams["legend.fontsize"]
250
- ).get_size_in_points()
291
+ legend_fs: Any = (
292
+ fontsize if fontsize is not None else plt.rcParams["legend.fontsize"]
293
+ )
294
+ hl: float = (
295
+ handlelength
296
+ if handlelength is not None
297
+ else float(plt.rcParams["legend.handlelength"])
298
+ )
299
+ htp: float = (
300
+ handletextpad
301
+ if handletextpad is not None
302
+ else float(plt.rcParams["legend.handletextpad"])
303
+ )
304
+ cs: float = (
305
+ columnspacing
306
+ if columnspacing is not None
307
+ else float(plt.rcParams["legend.columnspacing"])
308
+ )
309
+
310
+ fontsize_pt: float = FontProperties(size=legend_fs).get_size_in_points()
251
311
  fs_px: float = fontsize_pt * fig.dpi / 72.0
252
- handle_px: float = float(plt.rcParams["legend.handlelength"]) * fs_px
253
- pad_px: float = float(plt.rcParams["legend.handletextpad"]) * fs_px
254
- col_pad_px: float = float(plt.rcParams["legend.columnspacing"]) * fs_px
312
+ handle_px: float = hl * fs_px
313
+ pad_px: float = htp * fs_px
314
+ col_pad_px: float = cs * fs_px
255
315
 
256
316
  max_label_px = 0.0
257
317
  for label in labels:
258
318
  t = ax.text( # type: ignore[reportUnknownMemberType]
259
- 0, 0, label
319
+ 0, 0, label, fontsize=legend_fs
260
320
  )
261
321
  bbox = t.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
262
322
  max_label_px = max(max_label_px, bbox.width)
@@ -334,6 +394,92 @@ def compact_font_pt() -> float | None:
334
394
  return None if raw is None else float(raw)
335
395
 
336
396
 
397
+ def compact_legend_font_pt() -> float:
398
+ """Font size (points) for the compact legend -- never ``None``.
399
+
400
+ Falls back ``legend_font_pt`` -> ``font_pt`` -> ``9`` so the legend
401
+ never silently reverts to the stylesheet's body size (the original
402
+ "11 pt legend" bug): the legend must be measured and drawn at the
403
+ same, smaller size for the column count to be right.
404
+ """
405
+ return float(
406
+ COMPACT_CONFIG.get("legend_font_pt", COMPACT_CONFIG.get("font_pt", 9))
407
+ )
408
+
409
+
410
+ def compact_legend_kwargs() -> dict[str, Any]:
411
+ """Legend styling for compact charts, from the ``compact`` config.
412
+
413
+ Returns ``fontsize`` / ``handlelength`` / ``handletextpad`` /
414
+ ``columnspacing`` as concrete values (never ``None``), so the metrics
415
+ used to *measure* the column count in :func:`auto_ncol` match exactly
416
+ the metrics used to *draw* the legend in :func:`legend_above` -- the
417
+ invariant that prevents an over-counted ``ncol``. The smaller font
418
+ and shorter handles let several wide labels share one legend row.
419
+ """
420
+ return {
421
+ "fontsize": compact_legend_font_pt(),
422
+ "handlelength": float(COMPACT_CONFIG.get("legend_handlelength", 1.2)),
423
+ "handletextpad": float(COMPACT_CONFIG.get("legend_handletextpad", 0.4)),
424
+ "columnspacing": float(COMPACT_CONFIG.get("legend_columnspacing", 0.8)),
425
+ }
426
+
427
+
428
+ def compact_legend_max_ncol() -> int | None:
429
+ """Hard cap on the compact legend column count (``None`` if unset)."""
430
+ raw: Any = COMPACT_CONFIG.get("legend_max_ncol")
431
+ return None if raw is None else int(raw)
432
+
433
+
434
+ def compact_headroom_pad_pt() -> float:
435
+ """Points of clearance to leave above the top value label (0 if unset)."""
436
+ return float(COMPACT_CONFIG.get("annotate_headroom_pad_pt", 0))
437
+
438
+
439
+ class _StartLabeledConcise(ConciseDateFormatter):
440
+ """``ConciseDateFormatter`` that spells out the first (start) tick.
441
+
442
+ When the compact date axis pins the first datum as the leading tick
443
+ (see :func:`compact_date_axis`) that date is rarely a clean month
444
+ boundary, so the concise formatter would label it with a bare day
445
+ number (e.g. ``31``). This subclass relabels just that tick so the
446
+ chart's origin is unambiguous: a January start shows the **year only**
447
+ (``2020`` -- narrow, like the other year ticks), any other month shows
448
+ ``month year`` (``jul. 2020``). Every other tick keeps the concise
449
+ label.
450
+ """
451
+
452
+ def __init__(self, locator: Any, start_num: float) -> None:
453
+ # show_offset=False: don't append the trailing "year-month" offset
454
+ # text (it duplicated the month of the last tick, e.g. "jul." plus
455
+ # "2026-jul."). With it off, ConciseDateFormatter shows the year
456
+ # inline at year-boundary ticks instead.
457
+ super().__init__( # type: ignore[reportUnknownMemberType]
458
+ locator, show_offset=False
459
+ )
460
+ self._start_num = start_num
461
+
462
+ def format_ticks(self, values: Any) -> Any:
463
+ labels: list[str] = list(super().format_ticks(values))
464
+ for i, value in enumerate(values):
465
+ moment = num2date(value)
466
+ if abs(float(value) - self._start_num) < 0.5: # within half a day
467
+ if moment.month == 1:
468
+ # January start: the year alone is unambiguous and narrow,
469
+ # so it won't collide with the next (year) tick.
470
+ labels[i] = str(moment.year)
471
+ else:
472
+ # locale's %b may already carry a trailing dot ("ene.");
473
+ # strip it so we don't end up with "jul.. 2020".
474
+ month = moment.strftime("%b").lower().rstrip(".")
475
+ labels[i] = f"{month}. {moment.year}"
476
+ elif moment.month == 1:
477
+ # year boundary: show the year ("2026") instead of "ene." so
478
+ # the reader sees the year change without a trailing offset.
479
+ labels[i] = str(moment.year)
480
+ return labels
481
+
482
+
337
483
  def compact_date_axis(
338
484
  ax: Axes,
339
485
  data_min: Any,
@@ -341,15 +487,18 @@ def compact_date_axis(
341
487
  *,
342
488
  maxticks: int = 4,
343
489
  ) -> None:
344
- """Compact date x-axis: few horizontal ticks, last one near the data end.
490
+ """Compact date x-axis: few horizontal ticks, anchored to the data span.
345
491
 
346
492
  Ticks are computed over the **data span** ``[data_min,
347
493
  data_max]`` and pinned with a ``FixedLocator``, so none
348
494
  float in the right-hand margin that the value labels open
349
495
  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).
496
+ point as the tick step allows. When ``compact.date_show_start``
497
+ is true (the default) the first data point is pinned as the
498
+ leading tick, so the reader can see exactly when the series
499
+ begins. Requires the series to have been plotted with
500
+ ``x_compat=True`` (pandas' own date plotting ignores this
501
+ formatter).
353
502
  """
354
503
  lo: float = float(cast(Any, date2num(data_min)))
355
504
  hi: float = float(cast(Any, date2num(data_max)))
@@ -357,19 +506,80 @@ def compact_date_axis(
357
506
  candidates: list[float] = cast(
358
507
  "list[float]", locator.tick_values(data_min, data_max)
359
508
  )
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))
509
+ interior: list[float] = [t for t in candidates if lo < t <= hi]
510
+ if COMPACT_CONFIG.get("date_show_start", True):
511
+ # pin the first data point as the leading tick so the reader sees
512
+ # exactly when the series starts (the natural calendar tick often
513
+ # falls just *before* the first datum and is clipped by margins=0).
514
+ # Drop any auto tick that would crowd the start label.
515
+ min_gap: float = (hi - lo) * 0.10
516
+ interior = [t for t in interior if t - lo >= min_gap]
517
+ ticks: list[float] = [lo, *interior]
518
+ else:
519
+ ticks = [t for t in candidates if lo <= t <= hi] or [lo, hi]
520
+ show_start = bool(COMPACT_CONFIG.get("date_show_start", True))
521
+
522
+ def _set_ticks(values: list[float]) -> None:
523
+ loc = FixedLocator(values)
524
+ ax.xaxis.set_major_locator(loc)
525
+ ax.xaxis.set_major_formatter(
526
+ _StartLabeledConcise(loc, lo)
527
+ if show_start
528
+ else ConciseDateFormatter(loc)
529
+ )
530
+
531
+ _set_ticks(ticks)
366
532
  ax.tick_params( # type: ignore[reportUnknownMemberType]
367
533
  axis="x", rotation=0
368
534
  )
535
+
536
+ # A priori de-overlap: render the labels, measure their boxes, and drop
537
+ # any tick whose label would collide with the previous kept one (the
538
+ # start tick is always kept). Catches a wide start label (e.g. a January
539
+ # start widened to a year, or "jul. 2020") crowding the next tick.
540
+ fig = ax.get_figure()
541
+ if fig is not None and len(ticks) > 1:
542
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
543
+ renderer = fig.canvas.get_renderer() # type: ignore[reportUnknownMemberType]
544
+ boxes = [
545
+ lbl.get_window_extent(renderer) # type: ignore[reportUnknownArgumentType]
546
+ for lbl in ax.get_xticklabels()
547
+ ]
548
+ if len(boxes) == len(ticks):
549
+ pad_px = 2.0
550
+ kept: list[float] = [ticks[0]]
551
+ last_x1: float = boxes[0].x1
552
+ for tick, box in zip(ticks[1:], boxes[1:]):
553
+ if box.x0 <= last_x1 + pad_px:
554
+ continue # collides with previous kept label -> drop
555
+ kept.append(tick)
556
+ last_x1 = box.x1
557
+ if len(kept) < len(ticks):
558
+ _set_ticks(kept)
559
+
369
560
  for label in ax.get_xticklabels():
370
561
  label.set_horizontalalignment("center")
371
562
 
372
563
 
564
+ def compact_y_axis(ax: Axes) -> None:
565
+ """Denser y-ticks for compact charts (more reference numbers).
566
+
567
+ Compact charts are read at body-text size, so Matplotlib's default
568
+ of a couple of y-numbers on a short axis is too sparse. Bump the
569
+ count to ``compact.y_max_ticks`` "nice" steps (1/2/2.5/5/10 decades)
570
+ so the reader gets more gridline references -- e.g. every 20 instead
571
+ of every 50. Leaves the default locator untouched when the knob is
572
+ unset, and never changes the y-tick *formatter* set by
573
+ :func:`style_spines`.
574
+ """
575
+ raw: Any = COMPACT_CONFIG.get("y_max_ticks")
576
+ if raw is None:
577
+ return
578
+ ax.yaxis.set_major_locator(
579
+ MaxNLocator(nbins=int(raw), steps=[1, 2, 2.5, 5, 10])
580
+ )
581
+
582
+
373
583
  def legend_above(
374
584
  ax: Axes,
375
585
  handles: list[Any],
@@ -377,26 +587,35 @@ def legend_above(
377
587
  *,
378
588
  ncol: int,
379
589
  fontsize: float | None = None,
590
+ handlelength: float | None = None,
591
+ handletextpad: float | None = None,
592
+ columnspacing: float | None = None,
380
593
  ) -> 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.
594
+ """Place the legend in a band **above the axes, spanning the figure**.
595
+
596
+ Uses ``loc="outside upper center"`` so constrained layout reserves a
597
+ strip at the top of the *figure* and the legend may use the whole
598
+ figure width -- wider than the narrow compact axes, whose left edge
599
+ is eaten by the y-tick numbers and whose right edge is eaten by the
600
+ end-of-series value labels. Spanning the figure lets the maximum
601
+ number of columns share one row, which **minimises the vertical
602
+ space the legend steals from the plot** (fewer rows = taller chart).
603
+
604
+ ``fontsize`` / ``handlelength`` / ``handletextpad`` /
605
+ ``columnspacing`` style the legend (``None`` keeps the
606
+ ``rcParams`` default); the compact mode passes a smaller
607
+ font and shorter handles so more labels fit per row.
389
608
  """
390
- bbox: Any = COMPACT_CONFIG.get("legend_bbox", [0.0, 1.06, 1.0, 0.12])
391
- return ax.legend( # type: ignore[reportUnknownMemberType]
609
+ fig = ax.get_figure()
610
+ return fig.legend( # type: ignore[reportUnknownMemberType, reportOptionalMemberAccess]
392
611
  handles,
393
612
  labels,
394
- loc="lower left",
395
- bbox_to_anchor=tuple(bbox),
396
- mode="expand",
397
- borderaxespad=0.0,
613
+ loc="outside upper center",
398
614
  ncol=ncol,
399
615
  fontsize=fontsize,
616
+ handlelength=handlelength,
617
+ handletextpad=handletextpad,
618
+ columnspacing=columnspacing,
400
619
  frameon=False, # no box: less clutter in the tight compact figure
401
620
  )
402
621
 
@@ -452,13 +671,15 @@ def style_spines(
452
671
  ax.tick_params( # type: ignore[reportUnknownMemberType]
453
672
  which="minor", size=0, width=0
454
673
  )
674
+ # Set the tick-mark colour via tick_params (not by recolouring each
675
+ # Line2D): this stores it in the axis' tick kwargs, so ANY tick
676
+ # regenerated after this call inherits it. Compact mode swaps in a
677
+ # denser y-locator later, which rebuilds the tick objects; recolouring
678
+ # the lines directly here would leave those new ticks the default black
679
+ # (only the relocated ones would stay grey -- hence a lone black tick).
455
680
  ax.tick_params( # type: ignore[reportUnknownMemberType]
456
- axis="both", which="major"
681
+ axis="both", which="major", color=color
457
682
  )
458
- for tick in ax.get_xticklines():
459
- tick.set_markeredgecolor(color)
460
- for tick in ax.get_yticklines():
461
- tick.set_markeredgecolor(color)
462
683
 
463
684
 
464
685
  def style_baseline(
@@ -500,6 +721,7 @@ def annotate_last_values(
500
721
  weight: str | None = None,
501
722
  fontsize: float | None = None,
502
723
  right_pad_px: float = 10.0,
724
+ headroom_pad_pt: float = 0.0,
503
725
  ) -> None:
504
726
  """Label the last non-NaN value of each column on the right.
505
727
 
@@ -520,6 +742,13 @@ def annotate_last_values(
520
742
  ``right_pad_px`` (e.g. on compact charts) to open more
521
743
  blank space on the right and keep the labels clear of both
522
744
  the data lines and the right-hand y-tick labels.
745
+
746
+ ``headroom_pad_pt`` is the clearance kept above the top
747
+ label: if the packed stack would reach past the top
748
+ y-limit, that limit grows enough to keep this many points
749
+ free and the labels are re-packed at the new scale, so a
750
+ tall stack never spills into a legend above the axes (nor
751
+ is clipped at the top).
523
752
  """
524
753
  fig = ax.get_figure()
525
754
  if fig is None:
@@ -563,14 +792,39 @@ def annotate_last_values(
563
792
  sample.remove()
564
793
 
565
794
  entries.sort(key=lambda e: e[1])
566
- placements: list[float] = []
567
- prev_y = -float("inf")
568
- for _, val, _, _ in entries:
569
- y_display: float = trans.transform((0, val))[1] # type: ignore[reportUnknownArgumentType]
570
- if y_display - prev_y < text_height:
571
- y_display = prev_y + text_height
572
- placements.append(y_display)
573
- prev_y = y_display
795
+
796
+ def _pack(top_data: float) -> list[float]:
797
+ # Set the final y-scale BEFORE packing. Spacing is enforced in display
798
+ # space, so it must be measured at the scale the labels are drawn at;
799
+ # growing the y-limit *after* packing (the old approach) rescaled the
800
+ # data points and quietly reopened the overlaps packing should remove.
801
+ ax.set_ylim(top=top_data)
802
+ fig.canvas.draw() # type: ignore[reportUnknownMemberType]
803
+ packed: list[float] = []
804
+ prev = -float("inf")
805
+ for _, value, _, _ in entries:
806
+ y_disp: float = trans.transform((0, value))[1] # type: ignore[reportUnknownArgumentType]
807
+ if y_disp - prev < text_height:
808
+ y_disp = prev + text_height
809
+ packed.append(y_disp)
810
+ prev = y_disp
811
+ return packed
812
+
813
+ _, ymax = ax.get_ylim()
814
+ placements = _pack(ymax)
815
+
816
+ # The stack packs upward; if it reaches past the top y-limit it renders
817
+ # over the legend (compact, legend above) or is clipped (normal). Grow the
818
+ # y-limit so the top label keeps headroom_pad_pt of clearance, then RE-PACK
819
+ # so the spacing is exact at the new (final) scale.
820
+ pad_px = headroom_pad_pt * fig.dpi / 72.0
821
+ top_axes_px: float = trans.transform((0, ymax))[1] # type: ignore[reportUnknownArgumentType]
822
+ needed_px = placements[-1] + text_height / 2 + pad_px
823
+ if needed_px > top_axes_px:
824
+ new_top: float = trans.inverted().transform( # type: ignore[reportUnknownArgumentType]
825
+ (0, needed_px)
826
+ )[1]
827
+ placements = _pack(new_top)
574
828
 
575
829
  max_label_width = 0.0
576
830
  for (date, val, _, color), packed_y in zip(entries, placements):
@@ -593,7 +847,8 @@ def annotate_last_values(
593
847
 
594
848
  # labels spill right of the data, so widen the x-limit by the widest
595
849
  # label plus right_pad_px to keep them unclipped (and, with a larger
596
- # pad, clear of the right-hand y-tick labels).
850
+ # pad, clear of the right-hand y-tick labels). x is independent of the
851
+ # y-limit set above, so this stays correct.
597
852
  inv = trans.inverted()
598
853
  x0: float = inv.transform((0, 0))[0] # type: ignore[reportUnknownArgumentType]
599
854
  x1: float = inv.transform((max_label_width + right_pad_px, 0))[0] # type: ignore[reportUnknownArgumentType]
@@ -36,6 +36,7 @@ from tesorotools.artists._common import (
36
36
  Legend,
37
37
  adjust_figure_for_plot_size,
38
38
  auto_ncol,
39
+ dynamic_dpi,
39
40
  resolve_data,
40
41
  )
41
42
  from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
@@ -370,7 +371,7 @@ class HorizontalBarChart:
370
371
  _style_zero_baseline(ax, **AX_CONFIG["baseline"])
371
372
 
372
373
  fig.savefig( # type: ignore[reportUnknownMemberType]
373
- self.out_path
374
+ self.out_path, dpi=dynamic_dpi(fig)
374
375
  )
375
376
  plt.close(fig)
376
377
  return ax
@@ -572,7 +573,7 @@ class GroupedBarChart:
572
573
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
573
574
 
574
575
  fig.savefig( # type: ignore[reportUnknownMemberType]
575
- self.out_path
576
+ self.out_path, dpi=dynamic_dpi(fig)
576
577
  )
577
578
  plt.close(fig)
578
579
  return ax
@@ -32,8 +32,13 @@ from tesorotools.artists._common import (
32
32
  compact_date_axis,
33
33
  compact_figsize,
34
34
  compact_font_pt,
35
+ compact_headroom_pad_pt,
36
+ compact_legend_kwargs,
37
+ compact_legend_max_ncol,
38
+ compact_y_axis,
35
39
  declutter_spines,
36
40
  draw_vlines,
41
+ dynamic_dpi,
37
42
  legend_above,
38
43
  style_baseline,
39
44
  style_spines,
@@ -65,6 +70,17 @@ class LinePlot:
65
70
  (color, linestyle, ...).
66
71
  * ``baseline`` -- draw an axhline at 0 (or 100 when
67
72
  ``base_100``).
73
+ * ``ref_lines`` -- arbitrary horizontal reference lines;
74
+ a list of dicts ``{"y": float, "label"?: str,
75
+ "color"?, "linestyle"?, "linewidth"?, "in_legend"?:
76
+ bool}``. Dashed and above the data by default; an entry
77
+ with ``in_legend: true`` and a ``label`` also shows in
78
+ the legend (e.g. a long-run PER mean, a vol threshold).
79
+ * ``show_means`` / ``means_window`` -- draw each series'
80
+ historical mean as a dotted same-colour line (the old
81
+ ``medias=True``); the mean is taken over ``means_window``
82
+ (a ``(start, end)`` pair) or the whole series. Not
83
+ meaningful together with ``base_100``.
68
84
  * ``compact`` -- render at a fixed physical size (7.4 cm
69
85
  wide *including* the legend, from ``plots.yaml``) for
70
86
  placing two charts per row inside a Word table. Moves
@@ -99,6 +115,9 @@ class LinePlot:
99
115
  plot_size: tuple[float, float] | None = None,
100
116
  vlines: list[dict[str, Any]] | None = None,
101
117
  compact: bool = False,
118
+ ref_lines: list[dict[str, Any]] | None = None,
119
+ show_means: bool = False,
120
+ means_window: tuple[Any, Any] | None = None,
102
121
  ) -> None:
103
122
  if out_path.suffix != ".png":
104
123
  raise ValueError(f"out_path must be .png: {out_path}")
@@ -121,6 +140,9 @@ class LinePlot:
121
140
  self.plot_size = plot_size
122
141
  self.vlines = vlines or []
123
142
  self.compact = compact
143
+ self.ref_lines = ref_lines or []
144
+ self.show_means = show_means
145
+ self.means_window = means_window
124
146
 
125
147
  @classmethod
126
148
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -143,7 +165,8 @@ class LinePlot:
143
165
  ``base_100_date``, ``annotate``, ``annotate_color``,
144
166
  ``baseline``, ``fmt`` (``!format``), ``legend``
145
167
  (``!legend``), ``figsize``, ``series_styles``,
146
- ``plot_size``, ``vlines``, ``compact``.
168
+ ``plot_size``, ``vlines``, ``compact``, ``ref_lines``,
169
+ ``show_means``, ``means_window``.
147
170
 
148
171
  Example
149
172
  -------
@@ -261,6 +284,9 @@ class LinePlot:
261
284
  if self.compact
262
285
  else 1.6 * plt.rcParams["font.size"] * fig.dpi / 72.0
263
286
  ),
287
+ headroom_pad_pt=(
288
+ compact_headroom_pad_pt() if self.compact else 0.0
289
+ ),
264
290
  )
265
291
 
266
292
  style_spines(
@@ -273,6 +299,48 @@ class LinePlot:
273
299
  reference = 100 if self.base_100 else 0
274
300
  style_baseline(ax, reference, **AX_CONFIG["baseline"])
275
301
 
302
+ # arbitrary horizontal reference lines (e.g. a PER long-run mean, a
303
+ # volatility threshold). Dashed and in front of the data by default;
304
+ # only those flagged in_legend with a label enter the legend -- they
305
+ # are picked up automatically by get_legend_handles_labels() below.
306
+ for ref_line in self.ref_lines:
307
+ ref_label = ref_line.get("label")
308
+ in_legend = bool(ref_line.get("in_legend"))
309
+ ax.axhline( # type: ignore[reportUnknownMemberType]
310
+ ref_line["y"],
311
+ color=ref_line.get("color", AX_CONFIG["baseline"]["color"]),
312
+ linestyle=ref_line.get("linestyle", "--"),
313
+ linewidth=ref_line.get("linewidth", 0.9),
314
+ zorder=2.5,
315
+ label=ref_label if (in_legend and ref_label) else None,
316
+ )
317
+ lo, hi = ax.get_ylim()
318
+ ax.set_ylim(min(ref_line["y"], lo), max(ref_line["y"], hi))
319
+
320
+ # per-series historical mean as a dotted same-colour line (the old
321
+ # `medias=True`): the mean is taken over means_window (or the whole
322
+ # series) and scaled to match the plotted axis. Not meaningful with
323
+ # base_100, where the rebased axis no longer matches the raw mean.
324
+ if self.show_means:
325
+ mean_lines = {str(line.get_label()): line for line in ax.lines}
326
+ for col in plot_data.columns:
327
+ window: Any = (
328
+ self.data[col]
329
+ if self.means_window is None
330
+ else self.data.loc[slice(*self.means_window), col]
331
+ )
332
+ mean_val = float(window.dropna().mean()) * self.scale
333
+ line = mean_lines.get(self.series[col])
334
+ color = line.get_color() if line is not None else "black"
335
+ ax.axhline( # type: ignore[reportUnknownMemberType]
336
+ mean_val,
337
+ color=color,
338
+ linestyle=":",
339
+ linewidth=0.8,
340
+ alpha=0.55,
341
+ zorder=1,
342
+ )
343
+
276
344
  if self.compact:
277
345
  # pin the date ticks to the data span (after annotate widened the
278
346
  # x-limit) so the last tick sits next to the final data point and
@@ -283,33 +351,45 @@ class LinePlot:
283
351
  # spines. Normal mode keeps the house right-hand y-axis and box.
284
352
  ax.yaxis.tick_left()
285
353
  declutter_spines(ax)
354
+ # denser y-numbers (e.g. every 20 instead of every 50) so the
355
+ # small chart still has enough reference gridlines.
356
+ compact_y_axis(ax)
286
357
  if font_pt is not None:
287
358
  ax.tick_params( # type: ignore[reportUnknownMemberType]
288
359
  axis="both", labelsize=font_pt
289
360
  )
290
361
 
291
362
  if self.legend is not None:
292
- labels = [self.series[c] for c in plot_data.columns]
363
+ # measure and draw from the actual labelled artists, so any
364
+ # in-legend reference lines are counted in the column fit too.
293
365
  handles, label_strs = ax.get_legend_handles_labels()
294
366
  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
- )
367
+ # legend in a band ABOVE the plot, spanning the FIGURE width.
368
+ # ncol is auto-fitted to that full width (not the narrow axes)
369
+ # with the compact legend metrics (small font, short handles)
370
+ # so the maximum number of labels share a row -- fewer rows
371
+ # means the legend steals less height from the chart. Then
372
+ # hard-capped by legend_max_ncol. Override via !legend {ncol}.
373
+ legend_kw = compact_legend_kwargs()
374
+ if self.legend.ncol is not None:
375
+ ncol = self.legend.ncol
376
+ else:
377
+ fig_w_px: float = fig.get_size_inches()[0] * fig.dpi
378
+ ncol = auto_ncol(
379
+ ax, label_strs, available_width_px=fig_w_px, **legend_kw
380
+ )
381
+ max_ncol = compact_legend_max_ncol()
382
+ if max_ncol is not None:
383
+ ncol = min(ncol, max_ncol)
384
+ legend_above(ax, handles, label_strs, ncol=ncol, **legend_kw)
307
385
  else:
308
386
  fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
309
387
  ncol = (
310
388
  self.legend.ncol
311
389
  if self.legend.ncol is not None
312
- else auto_ncol(ax, labels, available_width_px=fig_width_px)
390
+ else auto_ncol(
391
+ ax, label_strs, available_width_px=fig_width_px
392
+ )
313
393
  )
314
394
  fig.legend( # type: ignore[reportUnknownMemberType]
315
395
  handles,
@@ -335,17 +415,16 @@ class LinePlot:
335
415
  """Persist *fig* as a PNG and return the path written.
336
416
 
337
417
  Defaults to ``self.out_path``; pass ``path`` to
338
- redirect or ``dpi`` to override the figure DPI for
339
- the saved file (useful when embedding in Word at
340
- fixed widths, where the default 500 dpi balloons
341
- file sizes).
418
+ redirect. ``dpi`` overrides the save resolution;
419
+ when omitted it is derived from the figure size via
420
+ :func:`dynamic_dpi` (size-adapted, never below the
421
+ sharpness floor) instead of the high layout DPI, so
422
+ large figures do not balloon the PNG.
342
423
  """
343
424
  target: Path = path if path is not None else self.out_path
344
- save_kwargs: dict[str, Any] = {}
345
- if dpi is not None:
346
- save_kwargs["dpi"] = dpi
425
+ save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
347
426
  fig.savefig( # type: ignore[reportUnknownMemberType]
348
- target, **save_kwargs
427
+ target, dpi=save_dpi
349
428
  )
350
429
  return target
351
430
 
@@ -39,7 +39,12 @@ from matplotlib.figure import Figure
39
39
  from matplotlib.patches import Rectangle
40
40
  from yaml.nodes import MappingNode
41
41
 
42
- from tesorotools.artists._common import FIG_CONFIG, Format, resolve_data
42
+ from tesorotools.artists._common import (
43
+ FIG_CONFIG,
44
+ Format,
45
+ dynamic_dpi,
46
+ resolve_data,
47
+ )
43
48
  from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
44
49
  from tesorotools.utils.template import TemplateLoader
45
50
 
@@ -246,7 +251,7 @@ class MatrixChart:
246
251
 
247
252
  self._style_axes(ax, row_ids, col_ids)
248
253
  fig.savefig( # type: ignore[reportUnknownMemberType]
249
- self.out_path
254
+ self.out_path, dpi=dynamic_dpi(fig)
250
255
  )
251
256
  plt.close(fig)
252
257
  return ax
@@ -18,6 +18,7 @@ from tesorotools.artists._common import (
18
18
  resolve_data,
19
19
  adjust_figure_for_plot_size,
20
20
  auto_ncol,
21
+ dynamic_dpi,
21
22
  style_baseline,
22
23
  style_spines,
23
24
  )
@@ -170,7 +171,7 @@ class StackedAreaPlot:
170
171
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
171
172
 
172
173
  fig.savefig( # type: ignore[reportUnknownMemberType]
173
- self.out_path
174
+ self.out_path, dpi=dynamic_dpi(fig)
174
175
  )
175
176
  plt.close(fig)
176
177
  return ax
@@ -415,7 +416,7 @@ class StackedBarPlot:
415
416
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
416
417
 
417
418
  fig.savefig( # type: ignore[reportUnknownMemberType]
418
- self.out_path
419
+ self.out_path, dpi=dynamic_dpi(fig)
419
420
  )
420
421
  plt.close(fig)
421
422
  return ax
@@ -16,6 +16,7 @@ from tesorotools.artists._common import (
16
16
  Format,
17
17
  Legend,
18
18
  resolve_data,
19
+ dynamic_dpi,
19
20
  style_baseline,
20
21
  style_spines,
21
22
  )
@@ -259,7 +260,7 @@ class TypeCurve:
259
260
  ncol=ncol,
260
261
  )
261
262
  fig.savefig( # type: ignore[reportUnknownMemberType]
262
- self.out_path
263
+ self.out_path, dpi=dynamic_dpi(fig)
263
264
  )
264
265
  plt.close(fig)
265
266
  return ax
@@ -39,6 +39,7 @@ from tesorotools.artists._common import (
39
39
  AX_CONFIG,
40
40
  FIG_CONFIG,
41
41
  Format,
42
+ dynamic_dpi,
42
43
  resolve_data,
43
44
  )
44
45
  from tesorotools.utils.matplotlib import PLOT_CONFIG, format_annotation
@@ -260,7 +261,7 @@ class VectorPlot:
260
261
 
261
262
  self._style_axes(ax, xs, ys)
262
263
  fig.savefig( # type: ignore[reportUnknownMemberType]
263
- self.out_path
264
+ self.out_path, dpi=dynamic_dpi(fig)
264
265
  )
265
266
  plt.close(fig)
266
267
  return ax
@@ -35,6 +35,7 @@ from tesorotools.artists._common import (
35
35
  compact_figsize,
36
36
  compact_font_pt,
37
37
  declutter_spines,
38
+ dynamic_dpi,
38
39
  resolve_data,
39
40
  style_spines,
40
41
  )
@@ -304,13 +305,16 @@ class Waterfall:
304
305
  path: Path | None = None,
305
306
  dpi: int | None = None,
306
307
  ) -> Path:
307
- """Persist *fig* as a PNG and return the path written."""
308
+ """Persist *fig* as a PNG and return the path written.
309
+
310
+ ``dpi`` overrides the save resolution; when omitted it is
311
+ derived from the figure size via :func:`dynamic_dpi`
312
+ (size-adapted, never below the sharpness floor).
313
+ """
308
314
  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
315
+ save_dpi: int = dpi if dpi is not None else dynamic_dpi(fig)
312
316
  fig.savefig( # type: ignore[reportUnknownMemberType]
313
- target, **save_kwargs
317
+ target, dpi=save_dpi
314
318
  )
315
319
  return target
316
320
 
@@ -4,9 +4,18 @@ style:
4
4
  font: Cabinet Grotesk
5
5
 
6
6
  figure:
7
- dpi: 500
7
+ dpi: 500 # DPI de LAYOUT (medidas, px de fuente, ancho de leyenda)
8
8
  layout: constrained
9
9
 
10
+ # DPI de GUARDADO dinámico: se adapta al tamaño físico de la figura para no
11
+ # malgastar memoria. El lado largo se acota a ~target_px (las figuras grandes
12
+ # se guardan con menos dpi -> ficheros más ligeros), pero nunca por debajo de
13
+ # min_dpi (nitidez de impresión: jamás pixelado) ni por encima del dpi de
14
+ # layout (más píxeles serían desperdicio).
15
+ save:
16
+ target_px: 2000
17
+ min_dpi: 300
18
+
10
19
  ax:
11
20
  spines:
12
21
  color: gray
@@ -75,17 +84,26 @@ line:
75
84
 
76
85
  # Modo "compact": gráfico pensado para ir dentro de una tabla del Word,
77
86
  # 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.
87
+ # es TOTAL e INCLUYE la leyenda, que se coloca en una banda ENCIMA que
88
+ # abarca el ancho COMPLETO de la figura. Dimensiones en centímetros.
80
89
  compact:
81
90
  width_cm: 7.4 # ancho total de la figura (gráfico + leyenda)
82
91
  height_cm: 5.5 # alto total de la figura
83
92
  # Tamaño de letra del gráfico EN PUNTOS. Como la figura se renderiza a su
84
93
  # tamaño físico real (7,4 cm) y se embebe sin reducir, estos puntos son los
85
94
  # 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)
95
+ font_pt: 11 # ticks + etiquetas de valor (tamaño de cuerpo)
96
+ # Leyenda en banda superior, a ancho de figura. ncol se calcula como el
97
+ # MÁXIMO de columnas que caben en ese ancho (menos filas = menos espacio
98
+ # robado al gráfico), topado por legend_max_ncol.
99
+ legend_font_pt: 9 # leyenda algo MENOR que el texto de cuerpo
100
+ legend_handlelength: 1.2 # línea de muestra corta: caben más etiquetas/fila
101
+ legend_handletextpad: 0.4 # hueco línea-texto en la leyenda
102
+ legend_columnspacing: 0.8 # hueco entre columnas de la leyenda
103
+ legend_max_ncol: 3 # tope duro de columnas de la leyenda
104
+ annotate_headroom_pad_pt: 4 # hueco por encima de la etiqueta de valor más alta
105
+ date_show_start: true # fija la fecha del primer dato como primer tick X
106
+ y_max_ticks: 10 # nº máx. de números en el eje Y (más referencias)
89
107
 
90
108
  table:
91
109
  style: Light Shading Accent 1
@@ -2,9 +2,10 @@ from typing import Any
2
2
 
3
3
  import matplotlib
4
4
  import matplotlib.font_manager
5
+ import matplotlib.style
5
6
 
6
7
  from .config import read_config
7
- from .globals import FONTS, PLOT_CONFIG_FILE
8
+ from .globals import FONTS, PLOT_CONFIG_FILE, STYLE_SHEET
8
9
 
9
10
  # this should only be done once
10
11
  PLOT_CONFIG: dict[str, Any] = read_config(PLOT_CONFIG_FILE)
@@ -17,6 +18,23 @@ def load_fonts() -> None:
17
18
  matplotlib.rcParams["font.family"] = PLOT_CONFIG["style"]["font"]
18
19
 
19
20
 
21
+ def apply_house_style() -> None:
22
+ """Apply the full tesorotools look: stylesheet first, then fonts.
23
+
24
+ ``matplotlib.style.use`` loads the colour cycle (``C0``/``C1``/... =
25
+ the Tesoro palette), grid and tick sizes from ``tesoro.mplstyle``;
26
+ :func:`load_fonts` then registers the bundled OTFs and sets
27
+ ``font.family``. Fonts are applied **last** so the registered family
28
+ always wins over any ``font.*`` key the stylesheet might carry.
29
+
30
+ Without this the artists only ran :func:`load_fonts`, so the palette
31
+ cycle was never activated and lines fell back to Matplotlib's default
32
+ blue/orange unless the caller set colours explicitly.
33
+ """
34
+ matplotlib.style.use(str(STYLE_SHEET))
35
+ load_fonts()
36
+
37
+
20
38
  # this is not really matplotlib specific, so it should be elsewhere
21
39
  def format_annotation(value: float, decimals: int, units: str) -> str:
22
40
  decimal_formatted: str = f"{value:_.{decimals}f}".replace(".", ",").replace(