tesorotools-python 0.0.33__tar.gz → 0.0.35__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 (68) hide show
  1. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/PKG-INFO +1 -1
  2. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/pyproject.toml +1 -1
  3. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/artists/line_plot.py +111 -43
  4. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/artists/stacked.py +25 -27
  5. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/artists/table.py +255 -225
  6. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/artists/type_curve.py +5 -3
  7. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/plots.yaml +0 -2
  8. tesorotools_python-0.0.35/src/tesorotools/driver.py +138 -0
  9. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/content/images.py +159 -152
  10. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/content/table.py +42 -7
  11. tesorotools_python-0.0.35/src/tesorotools/testing/__init__.py +5 -0
  12. tesorotools_python-0.0.35/src/tesorotools/testing/compare.py +147 -0
  13. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/utils/format.py +21 -0
  14. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/.gitignore +0 -0
  15. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/__init__.py +0 -0
  16. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/artists/__init__.py +0 -0
  17. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/artists/barh.md +0 -0
  18. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/artists/barh_plot.py +0 -0
  19. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/README.md +0 -0
  20. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  21. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  22. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  23. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  24. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  25. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  26. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  27. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  28. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/fonts/README.md +0 -0
  29. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  30. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/convert.py +0 -0
  31. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/data_sources/__init__.py +0 -0
  32. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/data_sources/debug.py +0 -0
  33. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/database/__init__.py +0 -0
  34. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/database/local.py +0 -0
  35. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/database/push.py +0 -0
  36. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/database/shared.py +0 -0
  37. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/dependencies/__init__.py +0 -0
  38. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/dependencies/node.py +0 -0
  39. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/dependencies/resolution.py +0 -0
  40. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/main.py +0 -0
  41. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/manifest.py +0 -0
  42. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/offsets/__init__.py +0 -0
  43. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/offsets/offsets.py +0 -0
  44. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/offsets/outliers.py +0 -0
  45. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/pipeline/__init__.py +0 -0
  46. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/pipeline/diagnose.py +0 -0
  47. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/pipeline/engine.py +0 -0
  48. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/pipeline/rules.py +0 -0
  49. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/providers/__init__.py +0 -0
  50. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/providers/base.py +0 -0
  51. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/providers/bde.py +0 -0
  52. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/providers/ecb.py +0 -0
  53. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/py.typed +0 -0
  54. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/__init__.py +0 -0
  55. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/content/__init__.py +0 -0
  56. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/content/content.py +0 -0
  57. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/content/section.py +0 -0
  58. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/content/subtitle.py +0 -0
  59. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/content/text.py +0 -0
  60. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/content/title.py +0 -0
  61. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/render/report.py +0 -0
  62. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/utils/__init__.py +0 -0
  63. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/utils/config.py +0 -0
  64. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/utils/globals.py +0 -0
  65. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/utils/matplotlib.py +0 -0
  66. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/utils/series.py +0 -0
  67. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/src/tesorotools/utils/shortcuts.py +0 -0
  68. {tesorotools_python-0.0.33 → tesorotools_python-0.0.35}/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.33
3
+ Version: 0.0.35
4
4
  Requires-Python: >=3.13
5
5
  Requires-Dist: babel>=2.17
6
6
  Requires-Dist: matplotlib>=3.10
@@ -1,7 +1,7 @@
1
1
  [project]
2
2
  name = "tesorotools-python"
3
3
  requires-python = ">=3.13"
4
- version = "0.0.33"
4
+ version = "0.0.35"
5
5
  dependencies = [
6
6
  # database and ORM
7
7
  "psycopg[binary]>=3.1",
@@ -1,7 +1,7 @@
1
1
  import datetime
2
2
  import locale
3
3
  from pathlib import Path
4
- from typing import Any, Self
4
+ from typing import Any, Self, cast
5
5
 
6
6
  import matplotlib.pyplot as plt
7
7
  import pandas as pd
@@ -218,6 +218,27 @@ def annotate_last_values(
218
218
  ax.set_xlim(xmin, xmax + (x1 - x0))
219
219
 
220
220
 
221
+ def draw_vlines(ax: Axes, vlines: list[dict[str, Any]]) -> None:
222
+ """Draw labelled vertical event markers on *ax*.
223
+
224
+ Each entry must provide ``x`` (date-like). Optional keys
225
+ (``label``, ``color``, ``linestyle``, ``linewidth``, ...) are
226
+ forwarded to ``ax.axvline``. ``linestyle`` defaults to
227
+ ``"dashed"`` so markers are visually distinct from data lines.
228
+ """
229
+ for vline in vlines:
230
+ kwargs: dict[str, Any] = dict(vline)
231
+ x_raw: Any = kwargs.pop("x")
232
+ # matplotlib's stub types axvline's x as float, but at runtime
233
+ # it accepts any date-like recognised by the date converter.
234
+ x_dt = cast(Any, pd.to_datetime(x_raw)) # type: ignore[reportUnknownMemberType]
235
+ kwargs.setdefault("linestyle", "dashed")
236
+ ax.axvline( # type: ignore[reportUnknownMemberType]
237
+ x=x_dt,
238
+ **kwargs,
239
+ )
240
+
241
+
221
242
  def style_spines(
222
243
  ax: Axes,
223
244
  decimals: int,
@@ -289,21 +310,20 @@ def style_baseline(
289
310
  reference: float = 0,
290
311
  **baseline_config: Any,
291
312
  ) -> None:
292
- color: str = baseline_config["color"]
313
+ """Draw a horizontal baseline at *reference*.
314
+
315
+ Always uses ``axhline`` (with high zorder) so callers that
316
+ later restyle the spines do not silently erase the baseline.
317
+ """
293
318
  bottom_lim, top_lim = ax.get_ylim()
294
319
  ax.set_ylim(
295
320
  bottom=min(reference, bottom_lim),
296
321
  top=max(reference, top_lim),
297
322
  )
298
- bottom_lim, top_lim = ax.get_ylim()
299
- if bottom_lim == reference:
300
- ax.spines["bottom"].set_edgecolor(color)
301
- elif top_lim == reference:
302
- ax.spines["top"].set_edgecolor(color)
303
- else:
304
- ax.axhline( # type: ignore[reportUnknownMemberType]
305
- y=reference, **baseline_config
306
- )
323
+ baseline_config.setdefault("zorder", 2.5)
324
+ ax.axhline( # type: ignore[reportUnknownMemberType]
325
+ y=reference, **baseline_config
326
+ )
307
327
 
308
328
 
309
329
  def plot_line_chart(
@@ -312,12 +332,12 @@ def plot_line_chart(
312
332
  *,
313
333
  base_100: bool,
314
334
  annotate: bool,
315
- format: dict[str, Any],
335
+ fmt: dict[str, Any],
316
336
  **kwargs: Any,
317
337
  ) -> None:
318
338
  if base_100:
319
339
  data = data / data.iloc[0, :] * 100
320
- if format["units"] == "p.b.":
340
+ if fmt["units"] == "p.b.":
321
341
  data = data * 100
322
342
  fig: Figure = plt.figure( # type: ignore[reportUnknownMemberType]
323
343
  **FIG_CONFIG
@@ -328,11 +348,13 @@ def plot_line_chart(
328
348
  pass
329
349
 
330
350
  reference = 100 if base_100 else 0
331
- style_spines(ax, **format, **AX_CONFIG["spines"])
351
+ style_spines(ax, **fmt, **AX_CONFIG["spines"])
332
352
  style_baseline(ax, reference, **AX_CONFIG["baseline"])
333
- ax.legend( # type: ignore[reportUnknownMemberType]
334
- loc="upper center",
335
- bbox_to_anchor=(0.5, LINE_PLOT_CONFIG["legend_sep"]),
353
+ handles, label_strs = ax.get_legend_handles_labels()
354
+ fig.legend( # type: ignore[reportUnknownMemberType]
355
+ handles,
356
+ label_strs,
357
+ loc="outside lower center",
336
358
  ncol=(
337
359
  kwargs["legend"]["ncol"]
338
360
  if kwargs.get("legend", None) is not None
@@ -386,10 +408,8 @@ class Legend:
386
408
  def __init__(
387
409
  self,
388
410
  ncol: int | None = None,
389
- sep: float | None = None,
390
411
  ) -> None:
391
412
  self.ncol = ncol
392
- self.sep = sep
393
413
 
394
414
  @classmethod
395
415
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -411,15 +431,17 @@ class LinePlot:
411
431
  start_date: datetime.datetime | None = None,
412
432
  end_date: datetime.datetime | None = None,
413
433
  base_100: bool = False,
434
+ base_100_date: datetime.datetime | str | None = None,
414
435
  annotate: bool = False,
415
436
  annotate_color: str | None = None,
416
437
  baseline: bool = False,
417
- format: Format | None = None,
438
+ fmt: Format | None = None,
418
439
  legend: Legend | None = None,
419
440
  data: pd.DataFrame | None = None,
420
441
  figsize: tuple[float, float] | None = None,
421
442
  series_styles: dict[str, dict[str, Any]] | None = None,
422
443
  plot_size: tuple[float, float] | None = None,
444
+ vlines: list[dict[str, Any]] | None = None,
423
445
  ) -> None:
424
446
 
425
447
  if out_path.suffix != ".png":
@@ -443,9 +465,10 @@ class LinePlot:
443
465
  raise ValueError("series is required")
444
466
 
445
467
  self.base_100 = base_100
468
+ self.base_100_date = base_100_date
446
469
  self.annotate = annotate
447
470
  self.annotate_color = annotate_color
448
- self.format = format
471
+ self.fmt = fmt
449
472
  self.start_date = start_date
450
473
  self.end_date = end_date
451
474
  self.series = series
@@ -455,6 +478,7 @@ class LinePlot:
455
478
  self.figsize = figsize
456
479
  self.series_styles = series_styles or {}
457
480
  self.plot_size = plot_size
481
+ self.vlines = vlines or []
458
482
 
459
483
  @classmethod
460
484
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -466,7 +490,13 @@ class LinePlot:
466
490
  line_plot_cfg["data_path"] = Path(line_plot_cfg["data_path"])
467
491
  return cls(**line_plot_cfg)
468
492
 
469
- def plot(self) -> Axes:
493
+ def build(self) -> tuple[Figure, Axes]:
494
+ """Render the chart in memory without writing to disk.
495
+
496
+ Returns the ``(Figure, Axes)`` so callers can post-process
497
+ before saving (extra annotations, override DPI, embed in a
498
+ composite figure). Pair with :meth:`save` to persist.
499
+ """
470
500
  start_date: pd.Timestamp = (
471
501
  self.data.index.min()
472
502
  if self.start_date is None
@@ -485,8 +515,19 @@ class LinePlot:
485
515
 
486
516
  plot_data = plot_data * self.scale
487
517
 
488
- if self.base_100: # maybe more flexible in the future
489
- plot_data = plot_data / plot_data.iloc[0, :] * 100
518
+ if self.base_100:
519
+ if self.base_100_date is None:
520
+ anchor: pd.Series[float] = plot_data.iloc[0, :]
521
+ else:
522
+ anchor_ts: pd.Timestamp = pd.to_datetime(self.base_100_date)
523
+ idx_pos: int = self.data.index.get_indexer(
524
+ pd.Index([anchor_ts]), method="nearest"
525
+ )[0]
526
+ anchor = (
527
+ self.data.iloc[idx_pos, :].loc[list(self.series.keys())]
528
+ * self.scale
529
+ )
530
+ plot_data = plot_data / anchor * 100
490
531
 
491
532
  fig_kw = dict(FIG_CONFIG)
492
533
  if self.figsize is not None:
@@ -500,13 +541,16 @@ class LinePlot:
500
541
  style = styles.get(col, {}) if styles else {}
501
542
  plot_data[col].plot(ax=ax, label=self.series[col], **style)
502
543
 
503
- assert self.format is not None
544
+ if self.vlines:
545
+ draw_vlines(ax, self.vlines)
546
+
547
+ assert self.fmt is not None
504
548
  if self.annotate:
505
549
  annotate_last_values(
506
550
  ax,
507
551
  plot_data,
508
- decimals=self.format.decimals,
509
- units=self.format.units,
552
+ decimals=self.fmt.decimals,
553
+ units=self.fmt.units,
510
554
  labels=self.series,
511
555
  series_styles=self.series_styles,
512
556
  annotate_color=self.annotate_color,
@@ -514,8 +558,8 @@ class LinePlot:
514
558
 
515
559
  style_spines( # maybe make this function accept a Format object
516
560
  ax,
517
- decimals=self.format.decimals,
518
- units=self.format.units,
561
+ decimals=self.fmt.decimals,
562
+ units=self.fmt.units,
519
563
  **AX_CONFIG["spines"],
520
564
  )
521
565
  if self.baseline:
@@ -524,30 +568,54 @@ class LinePlot:
524
568
 
525
569
  if self.legend is not None:
526
570
  labels = [self.series[c] for c in plot_data.columns]
571
+ handles, label_strs = ax.get_legend_handles_labels()
572
+ fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
527
573
  ncol = (
528
574
  self.legend.ncol
529
575
  if self.legend.ncol is not None
530
- else auto_ncol(ax, labels)
531
- )
532
- sep = (
533
- self.legend.sep
534
- if self.legend.sep is not None
535
- else LINE_PLOT_CONFIG["legend_sep"]
576
+ else auto_ncol(ax, labels, available_width_px=fig_width_px)
536
577
  )
537
- ax.legend( # type: ignore[reportUnknownMemberType]
538
- loc="upper center",
539
- bbox_to_anchor=(0.5, sep),
578
+ fig.legend( # type: ignore[reportUnknownMemberType]
579
+ handles,
580
+ label_strs,
581
+ loc="outside lower center",
540
582
  ncol=ncol,
541
583
  )
542
- else:
543
- ax.legend().set_visible( # type: ignore[reportUnknownMemberType]
544
- False
545
- )
546
584
 
547
585
  if self.plot_size is not None:
548
586
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
549
587
 
588
+ return fig, ax
589
+
590
+ def save(
591
+ self,
592
+ fig: Figure,
593
+ *,
594
+ path: Path | None = None,
595
+ dpi: int | None = None,
596
+ ) -> Path:
597
+ """Persist *fig* as a PNG. Returns the path written.
598
+
599
+ Defaults to ``self.out_path``; pass ``path`` to redirect or
600
+ ``dpi`` to override the figure DPI for the saved file
601
+ (useful when embedding in Word at fixed widths, where the
602
+ default 500 dpi balloons file sizes).
603
+ """
604
+ target: Path = path if path is not None else self.out_path
605
+ save_kwargs: dict[str, Any] = {}
606
+ if dpi is not None:
607
+ save_kwargs["dpi"] = dpi
550
608
  fig.savefig( # type: ignore[reportUnknownMemberType]
551
- self.out_path
609
+ target, **save_kwargs
552
610
  )
611
+ return target
612
+
613
+ def plot(self) -> Axes:
614
+ """Build the chart and persist it to ``self.out_path``.
615
+
616
+ Kept for backwards compatibility; new callers should prefer
617
+ :meth:`build` + :meth:`save` for finer control.
618
+ """
619
+ fig, ax = self.build()
620
+ self.save(fig)
553
621
  return ax
@@ -22,8 +22,6 @@ from tesorotools.artists.line_plot import (
22
22
  )
23
23
  from tesorotools.utils.config import TemplateLoader
24
24
 
25
- _DEFAULT_SEP = -0.125
26
-
27
25
 
28
26
  class StackedAreaPlot:
29
27
  """Stacked area chart with the tesorotools visual style.
@@ -43,7 +41,7 @@ class StackedAreaPlot:
43
41
  start_date: str | None = None,
44
42
  end_date: str | None = None,
45
43
  baseline: bool = False,
46
- format: Format | None = None,
44
+ fmt: Format | None = None,
47
45
  legend: Legend | None = None,
48
46
  figsize: tuple[float, float] | None = None,
49
47
  plot_size: tuple[float, float] | None = None,
@@ -57,7 +55,7 @@ class StackedAreaPlot:
57
55
  self.start_date = start_date
58
56
  self.end_date = end_date
59
57
  self.baseline = baseline
60
- self.format = format or Format()
58
+ self.fmt = fmt or Format()
61
59
  self.legend = legend
62
60
  self.figsize = figsize
63
61
  self.plot_size = plot_size
@@ -109,23 +107,25 @@ class StackedAreaPlot:
109
107
 
110
108
  style_spines(
111
109
  ax,
112
- decimals=self.format.decimals,
113
- units=self.format.units,
110
+ decimals=self.fmt.decimals,
111
+ units=self.fmt.units,
114
112
  **AX_CONFIG["spines"],
115
113
  )
116
114
  if self.baseline:
117
115
  style_baseline(ax, 0, **AX_CONFIG["baseline"])
118
116
 
117
+ fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
119
118
  legend_ncol = self.legend.ncol if self.legend else None
120
- ncol = legend_ncol if legend_ncol is not None else auto_ncol(ax, labels)
121
- sep = (
122
- self.legend.sep
123
- if self.legend and self.legend.sep is not None
124
- else _DEFAULT_SEP
119
+ ncol = (
120
+ legend_ncol
121
+ if legend_ncol is not None
122
+ else auto_ncol(ax, labels, available_width_px=fig_width_px)
125
123
  )
126
- ax.legend( # type: ignore[reportUnknownMemberType]
127
- loc="upper center",
128
- bbox_to_anchor=(0.5, sep),
124
+ handles, label_strs = ax.get_legend_handles_labels()
125
+ fig.legend( # type: ignore[reportUnknownMemberType]
126
+ handles,
127
+ label_strs,
128
+ loc="outside lower center",
129
129
  ncol=ncol,
130
130
  )
131
131
 
@@ -163,7 +163,7 @@ class StackedBarPlot:
163
163
  start_date: str | None = None,
164
164
  end_date: str | None = None,
165
165
  baseline: bool = True,
166
- format: Format | None = None,
166
+ fmt: Format | None = None,
167
167
  legend: Legend | None = None,
168
168
  figsize: tuple[float, float] | None = None,
169
169
  overlay_series: dict[str, str] | None = None,
@@ -180,7 +180,7 @@ class StackedBarPlot:
180
180
  self.start_date = start_date
181
181
  self.end_date = end_date
182
182
  self.baseline = baseline
183
- self.format = format or Format()
183
+ self.fmt = fmt or Format()
184
184
  self.legend = legend
185
185
  self.plot_size = plot_size
186
186
  self.figsize = figsize
@@ -313,8 +313,8 @@ class StackedBarPlot:
313
313
 
314
314
  style_spines(
315
315
  ax,
316
- decimals=self.format.decimals,
317
- units=self.format.units,
316
+ decimals=self.fmt.decimals,
317
+ units=self.fmt.units,
318
318
  **AX_CONFIG["spines"],
319
319
  )
320
320
  if self.baseline:
@@ -323,20 +323,18 @@ class StackedBarPlot:
323
323
  all_labels = list(self.series.values()) + list(
324
324
  self.overlay_series.values()
325
325
  )
326
+ fig_width_px: float = fig.get_size_inches()[0] * fig.dpi
326
327
  legend_ncol = self.legend.ncol if self.legend else None
327
328
  ncol = (
328
329
  legend_ncol
329
330
  if legend_ncol is not None
330
- else auto_ncol(ax, all_labels)
331
- )
332
- sep = (
333
- self.legend.sep
334
- if self.legend and self.legend.sep is not None
335
- else _DEFAULT_SEP
331
+ else auto_ncol(ax, all_labels, available_width_px=fig_width_px)
336
332
  )
337
- ax.legend( # type: ignore[reportUnknownMemberType]
338
- loc="upper center",
339
- bbox_to_anchor=(0.5, sep),
333
+ handles, label_strs = ax.get_legend_handles_labels()
334
+ fig.legend( # type: ignore[reportUnknownMemberType]
335
+ handles,
336
+ label_strs,
337
+ loc="outside lower center",
340
338
  ncol=ncol,
341
339
  )
342
340