tesorotools-python 0.0.33__tar.gz → 0.0.34__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 (67) hide show
  1. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/PKG-INFO +1 -1
  2. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/pyproject.toml +1 -1
  3. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/artists/line_plot.py +80 -5
  4. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/artists/table.py +255 -225
  5. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/content/images.py +159 -152
  6. tesorotools_python-0.0.34/src/tesorotools/testing/__init__.py +5 -0
  7. tesorotools_python-0.0.34/src/tesorotools/testing/compare.py +147 -0
  8. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/utils/format.py +21 -0
  9. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/.gitignore +0 -0
  10. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/__init__.py +0 -0
  11. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/artists/__init__.py +0 -0
  12. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/artists/barh.md +0 -0
  13. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/artists/barh_plot.py +0 -0
  14. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/artists/stacked.py +0 -0
  15. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/artists/type_curve.py +0 -0
  16. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/README.md +0 -0
  17. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/fonts/CabinetGrotesk-Black.otf +0 -0
  18. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/fonts/CabinetGrotesk-Bold.otf +0 -0
  19. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/fonts/CabinetGrotesk-Extrabold.otf +0 -0
  20. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/fonts/CabinetGrotesk-Extralight.otf +0 -0
  21. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/fonts/CabinetGrotesk-Light.otf +0 -0
  22. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/fonts/CabinetGrotesk-Medium.otf +0 -0
  23. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/fonts/CabinetGrotesk-Regular.otf +0 -0
  24. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/fonts/CabinetGrotesk-Thin.otf +0 -0
  25. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/fonts/README.md +0 -0
  26. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/plots.yaml +0 -0
  27. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/assets/tesoro.mplstyle +0 -0
  28. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/convert.py +0 -0
  29. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/data_sources/__init__.py +0 -0
  30. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/data_sources/debug.py +0 -0
  31. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/database/__init__.py +0 -0
  32. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/database/local.py +0 -0
  33. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/database/push.py +0 -0
  34. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/database/shared.py +0 -0
  35. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/dependencies/__init__.py +0 -0
  36. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/dependencies/node.py +0 -0
  37. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/dependencies/resolution.py +0 -0
  38. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/main.py +0 -0
  39. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/manifest.py +0 -0
  40. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/offsets/__init__.py +0 -0
  41. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/offsets/offsets.py +0 -0
  42. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/offsets/outliers.py +0 -0
  43. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/pipeline/__init__.py +0 -0
  44. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/pipeline/diagnose.py +0 -0
  45. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/pipeline/engine.py +0 -0
  46. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/pipeline/rules.py +0 -0
  47. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/providers/__init__.py +0 -0
  48. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/providers/base.py +0 -0
  49. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/providers/bde.py +0 -0
  50. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/providers/ecb.py +0 -0
  51. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/py.typed +0 -0
  52. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/__init__.py +0 -0
  53. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/content/__init__.py +0 -0
  54. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/content/content.py +0 -0
  55. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/content/section.py +0 -0
  56. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/content/subtitle.py +0 -0
  57. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/content/table.py +0 -0
  58. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/content/text.py +0 -0
  59. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/content/title.py +0 -0
  60. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/render/report.py +0 -0
  61. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/utils/__init__.py +0 -0
  62. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/utils/config.py +0 -0
  63. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/utils/globals.py +0 -0
  64. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/utils/matplotlib.py +0 -0
  65. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/utils/series.py +0 -0
  66. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/src/tesorotools/utils/shortcuts.py +0 -0
  67. {tesorotools_python-0.0.33 → tesorotools_python-0.0.34}/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.34
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.34"
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,
@@ -411,6 +432,7 @@ class LinePlot:
411
432
  start_date: datetime.datetime | None = None,
412
433
  end_date: datetime.datetime | None = None,
413
434
  base_100: bool = False,
435
+ base_100_date: datetime.datetime | str | None = None,
414
436
  annotate: bool = False,
415
437
  annotate_color: str | None = None,
416
438
  baseline: bool = False,
@@ -420,6 +442,7 @@ class LinePlot:
420
442
  figsize: tuple[float, float] | None = None,
421
443
  series_styles: dict[str, dict[str, Any]] | None = None,
422
444
  plot_size: tuple[float, float] | None = None,
445
+ vlines: list[dict[str, Any]] | None = None,
423
446
  ) -> None:
424
447
 
425
448
  if out_path.suffix != ".png":
@@ -443,6 +466,7 @@ class LinePlot:
443
466
  raise ValueError("series is required")
444
467
 
445
468
  self.base_100 = base_100
469
+ self.base_100_date = base_100_date
446
470
  self.annotate = annotate
447
471
  self.annotate_color = annotate_color
448
472
  self.format = format
@@ -455,6 +479,7 @@ class LinePlot:
455
479
  self.figsize = figsize
456
480
  self.series_styles = series_styles or {}
457
481
  self.plot_size = plot_size
482
+ self.vlines = vlines or []
458
483
 
459
484
  @classmethod
460
485
  def from_yaml(cls, loader: TemplateLoader, node: MappingNode) -> Self:
@@ -466,7 +491,13 @@ class LinePlot:
466
491
  line_plot_cfg["data_path"] = Path(line_plot_cfg["data_path"])
467
492
  return cls(**line_plot_cfg)
468
493
 
469
- def plot(self) -> Axes:
494
+ def build(self) -> tuple[Figure, Axes]:
495
+ """Render the chart in memory without writing to disk.
496
+
497
+ Returns the ``(Figure, Axes)`` so callers can post-process
498
+ before saving (extra annotations, override DPI, embed in a
499
+ composite figure). Pair with :meth:`save` to persist.
500
+ """
470
501
  start_date: pd.Timestamp = (
471
502
  self.data.index.min()
472
503
  if self.start_date is None
@@ -485,8 +516,19 @@ class LinePlot:
485
516
 
486
517
  plot_data = plot_data * self.scale
487
518
 
488
- if self.base_100: # maybe more flexible in the future
489
- plot_data = plot_data / plot_data.iloc[0, :] * 100
519
+ if self.base_100:
520
+ if self.base_100_date is None:
521
+ anchor: pd.Series[float] = plot_data.iloc[0, :]
522
+ else:
523
+ anchor_ts: pd.Timestamp = pd.to_datetime(self.base_100_date)
524
+ idx_pos: int = self.data.index.get_indexer(
525
+ pd.Index([anchor_ts]), method="nearest"
526
+ )[0]
527
+ anchor = (
528
+ self.data.iloc[idx_pos, :].loc[list(self.series.keys())]
529
+ * self.scale
530
+ )
531
+ plot_data = plot_data / anchor * 100
490
532
 
491
533
  fig_kw = dict(FIG_CONFIG)
492
534
  if self.figsize is not None:
@@ -500,6 +542,9 @@ class LinePlot:
500
542
  style = styles.get(col, {}) if styles else {}
501
543
  plot_data[col].plot(ax=ax, label=self.series[col], **style)
502
544
 
545
+ if self.vlines:
546
+ draw_vlines(ax, self.vlines)
547
+
503
548
  assert self.format is not None
504
549
  if self.annotate:
505
550
  annotate_last_values(
@@ -547,7 +592,37 @@ class LinePlot:
547
592
  if self.plot_size is not None:
548
593
  adjust_figure_for_plot_size(fig, ax, self.plot_size)
549
594
 
595
+ return fig, ax
596
+
597
+ def save(
598
+ self,
599
+ fig: Figure,
600
+ *,
601
+ path: Path | None = None,
602
+ dpi: int | None = None,
603
+ ) -> Path:
604
+ """Persist *fig* as a PNG. Returns the path written.
605
+
606
+ Defaults to ``self.out_path``; pass ``path`` to redirect or
607
+ ``dpi`` to override the figure DPI for the saved file
608
+ (useful when embedding in Word at fixed widths, where the
609
+ default 500 dpi balloons file sizes).
610
+ """
611
+ target: Path = path if path is not None else self.out_path
612
+ save_kwargs: dict[str, Any] = {}
613
+ if dpi is not None:
614
+ save_kwargs["dpi"] = dpi
550
615
  fig.savefig( # type: ignore[reportUnknownMemberType]
551
- self.out_path
616
+ target, **save_kwargs
552
617
  )
618
+ return target
619
+
620
+ def plot(self) -> Axes:
621
+ """Build the chart and persist it to ``self.out_path``.
622
+
623
+ Kept for backwards compatibility; new callers should prefer
624
+ :meth:`build` + :meth:`save` for finer control.
625
+ """
626
+ fig, ax = self.build()
627
+ self.save(fig)
553
628
  return ax
@@ -1,225 +1,255 @@
1
- from __future__ import annotations
2
-
3
- from math import floor
4
- from pathlib import Path
5
- from typing import Any
6
-
7
- import pandas as pd
8
-
9
- from tesorotools.dependencies.resolution import collect_series
10
- from tesorotools.offsets.outliers import flag_outliers
11
- from tesorotools.utils.matplotlib import format_annotation, is_zero
12
-
13
- # this file is by far the worst and most spaghettified, must be rewritten
14
-
15
- # to global config
16
- GOOD: str = "00c800"
17
- BAD: str = "c80000"
18
- THRESHOLD: float = 1
19
- SHADE_LEVELS: int = 2
20
-
21
-
22
- def _shade_intensity(
23
- ratio: float,
24
- shade_levels: int = 2,
25
- continuous: bool = False,
26
- ) -> str:
27
- # intensity may vary from 150 (highest) to 255 (lowest), a grand difference of 105
28
- # there are SHADE_LEVELS levels, so increments will be of 105/SHADE_LEVELS
29
- corrected_ratio: float = min(ratio, shade_levels)
30
- corrected_ratio = (
31
- floor(corrected_ratio) if not continuous else corrected_ratio
32
- )
33
- increment: float = (corrected_ratio - 1) * (105 / shade_levels)
34
- intensity: float = 255 - increment
35
- intensity_hex: str = f"{int(intensity):x}"
36
- return intensity_hex
37
-
38
-
39
- def _generate_column(
40
- column_data: pd.Series[Any],
41
- column_cfg: dict[str, Any],
42
- outliers_flags: pd.Series[Any] | None = None,
43
- ) -> tuple[pd.Series[Any], pd.Series[Any], pd.Series[Any]]:
44
- # TODO: factor out
45
- # data
46
- if column_cfg["show_units_in_title"]:
47
- column_data.name = f"{column_cfg['name']} ({column_cfg['unit']})"
48
- else:
49
- column_data.name = column_cfg["name"]
50
- column_cfg["formatted_name"] = column_data.name
51
-
52
- unit = (
53
- column_cfg["unit"]
54
- if column_cfg["show_units_in_cell"] and column_cfg["unit"] is not None
55
- else ""
56
- )
57
- scaled_data: pd.Series[Any] = column_data * column_cfg["scale"]
58
-
59
- def _fmt(x: float) -> str:
60
- return format_annotation(x, decimals=column_cfg["decimals"], units=unit)
61
-
62
- formatted_data: pd.Series[Any] = scaled_data.apply(_fmt)
63
-
64
- def _check_zero(x: float) -> bool:
65
- return is_zero(x, decimals=column_cfg["decimals"])
66
-
67
- zeros: pd.Series[Any] = scaled_data.apply(_check_zero)
68
- positives: pd.Series[Any] = scaled_data > 0
69
- negatives: pd.Series[Any] = scaled_data < 0
70
-
71
- # colors
72
- colors_cfg: bool = column_cfg["colors"]
73
- color_data: pd.Series[Any] = pd.Series(
74
- index=formatted_data.index,
75
- name=column_data.name,
76
- dtype=str,
77
- )
78
- positive_good: bool = False
79
- if colors_cfg:
80
- positive_good = column_cfg["positive_good"]
81
- color_data[positives] = GOOD if positive_good else BAD
82
- color_data.loc[negatives] = BAD if positive_good else GOOD
83
- color_data.loc[zeros.values] = pd.NA # type: ignore[index]
84
-
85
- # shades
86
- shade_data: pd.Series[Any] = pd.Series(
87
- index=formatted_data.index,
88
- name=column_data.name,
89
- dtype=str,
90
- )
91
- if outliers_flags is not None:
92
- thresholds: pd.Series[Any] = abs(outliers_flags / THRESHOLD)
93
-
94
- def _shade(x: float) -> str:
95
- return _shade_intensity(x, SHADE_LEVELS)
96
-
97
- intensities: pd.Series[Any] = thresholds.apply(_shade)
98
-
99
- def _color_pos(x: str) -> str:
100
- return f"00{x}00" if positive_good else f"{x}0000"
101
-
102
- def _color_neg(x: str) -> str:
103
- return f"{x}0000" if positive_good else f"00{x}00"
104
-
105
- shade_data[(thresholds >= 1) & (outliers_flags > 0)] = intensities[
106
- (thresholds >= 1) & (outliers_flags > 0)
107
- ].apply(_color_pos)
108
- shade_data[(thresholds >= 1) & (outliers_flags < 0)] = intensities[
109
- (thresholds >= 1) & (outliers_flags < 0)
110
- ].apply(_color_neg)
111
-
112
- return formatted_data, color_data, shade_data
113
-
114
-
115
- def _generate_block(
116
- block_data: pd.DataFrame, block_cfg: dict[str, Any]
117
- ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
118
- columns: dict[str, Any] = block_cfg["columns"]
119
- formatted_columns: list[pd.Series[Any]] = []
120
- color_columns: list[pd.Series[Any]] = []
121
- shade_columns: list[pd.Series[Any]] = []
122
- sort_idx: pd.Index[Any] | None = None
123
- for column_name, column_cfg in columns.items():
124
- last_date: pd.Timestamp = block_data.index.get_level_values(
125
- level=0
126
- ).max()
127
- block_data = block_data.rename(columns=block_cfg["series"])
128
- offset: str = column_cfg["offset"]
129
- difference: str = column_cfg["difference"]
130
- stat: str = column_cfg["stat"]
131
- outliers: bool = column_cfg["outliers"]
132
-
133
- stat_data: pd.DataFrame = block_data.loc[
134
- (last_date, offset, difference, slice(None)), :
135
- ]
136
- stat_data.index = stat_data.index.get_level_values(level=-1)
137
-
138
- outliers_flags: pd.Series[Any] | None = None
139
- if outliers:
140
- outliers_flags = flag_outliers(stat_data.T)
141
-
142
- column_data: pd.Series[Any] = stat_data.loc[stat, :] # type: ignore[assignment]
143
- # sort capability
144
- sort: str | None = block_cfg.get("sort", None)
145
- if sort is not None and column_name == sort:
146
- column_data = column_data.sort_values(ascending=False)
147
- sort_idx = column_data.index
148
- formatted_column, color_column, shade_column = _generate_column(
149
- column_data, column_cfg, outliers_flags
150
- )
151
- formatted_columns.append(formatted_column)
152
- color_columns.append(color_column)
153
- shade_columns.append(shade_column)
154
-
155
- if sort_idx is not None:
156
- formatted_columns = [s.reindex(sort_idx) for s in formatted_columns]
157
- color_columns = [s.reindex(sort_idx) for s in color_columns]
158
- shade_columns = [s.reindex(sort_idx) for s in shade_columns]
159
-
160
- formatted_block: pd.DataFrame = pd.concat(formatted_columns, axis=1)
161
- color_block: pd.DataFrame = pd.concat(color_columns, axis=1)
162
- shade_block: pd.DataFrame = pd.concat(shade_columns, axis=1)
163
-
164
- formatted_block.columns.name = block_cfg["title"]
165
- color_block.columns.name = block_cfg["title"]
166
- shade_block.columns.name = block_cfg["title"]
167
- return formatted_block, color_block, shade_block
168
-
169
-
170
- def generate_table(
171
- table_data: pd.DataFrame, table_cfg: dict[str, Any]
172
- ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
173
- blocks: dict[str, dict[str, Any]] = table_cfg["blocks"]
174
- formatted_blocks: list[pd.DataFrame] = []
175
- color_blocks: list[pd.DataFrame] = []
176
- shade_blocks: list[pd.DataFrame] = []
177
- axis = 0 if table_cfg["axis"] == "vertical" else 1
178
- # sorting capabilities
179
- for _block_name, block_cfg in blocks.items():
180
- series_dict: dict[str, str] = block_cfg["series"]
181
- block_data: pd.DataFrame = table_data.loc[:, series_dict.keys()]
182
- formatted_block, color_block, shade_block = _generate_block(
183
- block_data, block_cfg
184
- )
185
- formatted_blocks.append(formatted_block)
186
- color_blocks.append(color_block)
187
- shade_blocks.append(shade_block)
188
- formatted_table: pd.DataFrame = pd.concat(
189
- formatted_blocks,
190
- axis=axis,
191
- keys=[
192
- formatted_block.columns.name for formatted_block in formatted_blocks
193
- ],
194
- )
195
- color_table: pd.DataFrame = pd.concat(
196
- color_blocks,
197
- axis=axis,
198
- keys=[
199
- formatted_block.columns.name for formatted_block in formatted_blocks
200
- ],
201
- )
202
- shade_table: pd.DataFrame = pd.concat(
203
- shade_blocks,
204
- axis=axis,
205
- keys=[
206
- formatted_block.columns.name for formatted_block in formatted_blocks
207
- ],
208
- )
209
- return formatted_table, color_table, shade_table
210
-
211
-
212
- def generate_tables_from_flash(
213
- out_path: Path,
214
- flash: pd.DataFrame,
215
- config_dicts: dict[str, dict[str, Any]],
216
- ) -> None:
217
- for table_name, table_cfg in config_dicts.items():
218
- series: list[str] = list(collect_series(table_cfg))
219
- table_data: pd.DataFrame = flash.loc[:, series]
220
- formatted_table, color_table, shade_table = generate_table(
221
- table_data, table_cfg
222
- )
223
- formatted_table.to_feather(out_path / f"{table_name}.feather")
224
- color_table.to_feather(out_path / f"{table_name}_color.feather")
225
- shade_table.to_feather(out_path / f"{table_name}_shade.feather")
1
+ from __future__ import annotations
2
+
3
+ from math import floor
4
+ from pathlib import Path
5
+ from typing import Any, Callable
6
+
7
+ import pandas as pd
8
+
9
+ from tesorotools.dependencies.resolution import collect_series
10
+ from tesorotools.offsets.outliers import flag_outliers
11
+ from tesorotools.utils.matplotlib import format_annotation, is_zero
12
+
13
+ # this file is by far the worst and most spaghettified, must be rewritten
14
+
15
+ # to global config
16
+ GOOD: str = "00c800"
17
+ BAD: str = "c80000"
18
+ THRESHOLD: float = 1
19
+ SHADE_LEVELS: int = 2
20
+
21
+
22
+ def _shade_intensity(
23
+ ratio: float,
24
+ shade_levels: int = 2,
25
+ continuous: bool = False,
26
+ ) -> str:
27
+ # intensity may vary from 150 (highest) to 255 (lowest), a grand difference of 105
28
+ # there are SHADE_LEVELS levels, so increments will be of 105/SHADE_LEVELS
29
+ corrected_ratio: float = min(ratio, shade_levels)
30
+ corrected_ratio = (
31
+ floor(corrected_ratio) if not continuous else corrected_ratio
32
+ )
33
+ increment: float = (corrected_ratio - 1) * (105 / shade_levels)
34
+ intensity: float = 255 - increment
35
+ intensity_hex: str = f"{int(intensity):x}"
36
+ return intensity_hex
37
+
38
+
39
+ def make_shade_fn(
40
+ levels: int = 2,
41
+ *,
42
+ cap: float | None = None,
43
+ continuous: bool = False,
44
+ ) -> Callable[[float], str]:
45
+ """Build a closure that maps an outlier ratio to a hex intensity.
46
+
47
+ ``levels`` controls how many discrete steps the intensity has
48
+ between 255 (lightest) and 150 (darkest); ``cap`` is the ratio at
49
+ which intensity saturates (defaults to ``levels``); ``continuous``
50
+ skips the discretisation step and yields a smooth gradient.
51
+
52
+ The returned callable is suitable as a per-cell shade resolver in
53
+ a custom rendering pipeline that does not go through the
54
+ ``_generate_column`` cube path.
55
+ """
56
+ cap_value: float = float(levels if cap is None else cap)
57
+
58
+ def _shade(ratio: float) -> str:
59
+ corrected: float = min(ratio, cap_value)
60
+ if not continuous:
61
+ corrected = float(floor(corrected))
62
+ increment: float = (corrected - 1) * (105 / levels)
63
+ intensity: float = 255 - increment
64
+ return f"{int(intensity):x}"
65
+
66
+ return _shade
67
+
68
+
69
+ def _generate_column(
70
+ column_data: pd.Series[Any],
71
+ column_cfg: dict[str, Any],
72
+ outliers_flags: pd.Series[Any] | None = None,
73
+ ) -> tuple[pd.Series[Any], pd.Series[Any], pd.Series[Any]]:
74
+ # TODO: factor out
75
+ # data
76
+ if column_cfg["show_units_in_title"]:
77
+ column_data.name = f"{column_cfg['name']} ({column_cfg['unit']})"
78
+ else:
79
+ column_data.name = column_cfg["name"]
80
+ column_cfg["formatted_name"] = column_data.name
81
+
82
+ unit = (
83
+ column_cfg["unit"]
84
+ if column_cfg["show_units_in_cell"] and column_cfg["unit"] is not None
85
+ else ""
86
+ )
87
+ scaled_data: pd.Series[Any] = column_data * column_cfg["scale"]
88
+
89
+ def _fmt(x: float) -> str:
90
+ return format_annotation(x, decimals=column_cfg["decimals"], units=unit)
91
+
92
+ formatted_data: pd.Series[Any] = scaled_data.apply(_fmt)
93
+
94
+ def _check_zero(x: float) -> bool:
95
+ return is_zero(x, decimals=column_cfg["decimals"])
96
+
97
+ zeros: pd.Series[Any] = scaled_data.apply(_check_zero)
98
+ positives: pd.Series[Any] = scaled_data > 0
99
+ negatives: pd.Series[Any] = scaled_data < 0
100
+
101
+ # colors
102
+ colors_cfg: bool = column_cfg["colors"]
103
+ color_data: pd.Series[Any] = pd.Series(
104
+ index=formatted_data.index,
105
+ name=column_data.name,
106
+ dtype=str,
107
+ )
108
+ positive_good: bool = False
109
+ if colors_cfg:
110
+ positive_good = column_cfg["positive_good"]
111
+ color_data[positives] = GOOD if positive_good else BAD
112
+ color_data.loc[negatives] = BAD if positive_good else GOOD
113
+ color_data.loc[zeros.values] = pd.NA # type: ignore[index]
114
+
115
+ # shades
116
+ shade_data: pd.Series[Any] = pd.Series(
117
+ index=formatted_data.index,
118
+ name=column_data.name,
119
+ dtype=str,
120
+ )
121
+ if outliers_flags is not None:
122
+ thresholds: pd.Series[Any] = abs(outliers_flags / THRESHOLD)
123
+
124
+ def _shade(x: float) -> str:
125
+ return _shade_intensity(x, SHADE_LEVELS)
126
+
127
+ intensities: pd.Series[Any] = thresholds.apply(_shade)
128
+
129
+ def _color_pos(x: str) -> str:
130
+ return f"00{x}00" if positive_good else f"{x}0000"
131
+
132
+ def _color_neg(x: str) -> str:
133
+ return f"{x}0000" if positive_good else f"00{x}00"
134
+
135
+ shade_data[(thresholds >= 1) & (outliers_flags > 0)] = intensities[
136
+ (thresholds >= 1) & (outliers_flags > 0)
137
+ ].apply(_color_pos)
138
+ shade_data[(thresholds >= 1) & (outliers_flags < 0)] = intensities[
139
+ (thresholds >= 1) & (outliers_flags < 0)
140
+ ].apply(_color_neg)
141
+
142
+ return formatted_data, color_data, shade_data
143
+
144
+
145
+ def _generate_block(
146
+ block_data: pd.DataFrame, block_cfg: dict[str, Any]
147
+ ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
148
+ columns: dict[str, Any] = block_cfg["columns"]
149
+ formatted_columns: list[pd.Series[Any]] = []
150
+ color_columns: list[pd.Series[Any]] = []
151
+ shade_columns: list[pd.Series[Any]] = []
152
+ sort_idx: pd.Index[Any] | None = None
153
+ for column_name, column_cfg in columns.items():
154
+ last_date: pd.Timestamp = block_data.index.get_level_values(
155
+ level=0
156
+ ).max()
157
+ block_data = block_data.rename(columns=block_cfg["series"])
158
+ offset: str = column_cfg["offset"]
159
+ difference: str = column_cfg["difference"]
160
+ stat: str = column_cfg["stat"]
161
+ outliers: bool = column_cfg["outliers"]
162
+
163
+ stat_data: pd.DataFrame = block_data.loc[
164
+ (last_date, offset, difference, slice(None)), :
165
+ ]
166
+ stat_data.index = stat_data.index.get_level_values(level=-1)
167
+
168
+ outliers_flags: pd.Series[Any] | None = None
169
+ if outliers:
170
+ outliers_flags = flag_outliers(stat_data.T)
171
+
172
+ column_data: pd.Series[Any] = stat_data.loc[stat, :] # type: ignore[assignment]
173
+ # sort capability
174
+ sort: str | None = block_cfg.get("sort", None)
175
+ if sort is not None and column_name == sort:
176
+ column_data = column_data.sort_values(ascending=False)
177
+ sort_idx = column_data.index
178
+ formatted_column, color_column, shade_column = _generate_column(
179
+ column_data, column_cfg, outliers_flags
180
+ )
181
+ formatted_columns.append(formatted_column)
182
+ color_columns.append(color_column)
183
+ shade_columns.append(shade_column)
184
+
185
+ if sort_idx is not None:
186
+ formatted_columns = [s.reindex(sort_idx) for s in formatted_columns]
187
+ color_columns = [s.reindex(sort_idx) for s in color_columns]
188
+ shade_columns = [s.reindex(sort_idx) for s in shade_columns]
189
+
190
+ formatted_block: pd.DataFrame = pd.concat(formatted_columns, axis=1)
191
+ color_block: pd.DataFrame = pd.concat(color_columns, axis=1)
192
+ shade_block: pd.DataFrame = pd.concat(shade_columns, axis=1)
193
+
194
+ formatted_block.columns.name = block_cfg["title"]
195
+ color_block.columns.name = block_cfg["title"]
196
+ shade_block.columns.name = block_cfg["title"]
197
+ return formatted_block, color_block, shade_block
198
+
199
+
200
+ def generate_table(
201
+ table_data: pd.DataFrame, table_cfg: dict[str, Any]
202
+ ) -> tuple[pd.DataFrame, pd.DataFrame, pd.DataFrame]:
203
+ blocks: dict[str, dict[str, Any]] = table_cfg["blocks"]
204
+ formatted_blocks: list[pd.DataFrame] = []
205
+ color_blocks: list[pd.DataFrame] = []
206
+ shade_blocks: list[pd.DataFrame] = []
207
+ axis = 0 if table_cfg["axis"] == "vertical" else 1
208
+ # sorting capabilities
209
+ for _block_name, block_cfg in blocks.items():
210
+ series_dict: dict[str, str] = block_cfg["series"]
211
+ block_data: pd.DataFrame = table_data.loc[:, series_dict.keys()]
212
+ formatted_block, color_block, shade_block = _generate_block(
213
+ block_data, block_cfg
214
+ )
215
+ formatted_blocks.append(formatted_block)
216
+ color_blocks.append(color_block)
217
+ shade_blocks.append(shade_block)
218
+ formatted_table: pd.DataFrame = pd.concat(
219
+ formatted_blocks,
220
+ axis=axis,
221
+ keys=[
222
+ formatted_block.columns.name for formatted_block in formatted_blocks
223
+ ],
224
+ )
225
+ color_table: pd.DataFrame = pd.concat(
226
+ color_blocks,
227
+ axis=axis,
228
+ keys=[
229
+ formatted_block.columns.name for formatted_block in formatted_blocks
230
+ ],
231
+ )
232
+ shade_table: pd.DataFrame = pd.concat(
233
+ shade_blocks,
234
+ axis=axis,
235
+ keys=[
236
+ formatted_block.columns.name for formatted_block in formatted_blocks
237
+ ],
238
+ )
239
+ return formatted_table, color_table, shade_table
240
+
241
+
242
+ def generate_tables_from_flash(
243
+ out_path: Path,
244
+ flash: pd.DataFrame,
245
+ config_dicts: dict[str, dict[str, Any]],
246
+ ) -> None:
247
+ for table_name, table_cfg in config_dicts.items():
248
+ series: list[str] = list(collect_series(table_cfg))
249
+ table_data: pd.DataFrame = flash.loc[:, series]
250
+ formatted_table, color_table, shade_table = generate_table(
251
+ table_data, table_cfg
252
+ )
253
+ formatted_table.to_feather(out_path / f"{table_name}.feather")
254
+ color_table.to_feather(out_path / f"{table_name}_color.feather")
255
+ shade_table.to_feather(out_path / f"{table_name}_shade.feather")