openretailscience 0.45.0__py3-none-any.whl

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 (60) hide show
  1. openretailscience/__init__.py +3 -0
  2. openretailscience/analysis/__init__.py +0 -0
  3. openretailscience/analysis/cohort.py +185 -0
  4. openretailscience/analysis/composite_rank.py +372 -0
  5. openretailscience/analysis/cross_shop.py +354 -0
  6. openretailscience/analysis/customer.py +459 -0
  7. openretailscience/analysis/customer_decision_hierarchy.py +359 -0
  8. openretailscience/analysis/gain_loss.py +340 -0
  9. openretailscience/analysis/haversine.py +50 -0
  10. openretailscience/analysis/product_association.py +418 -0
  11. openretailscience/analysis/revenue_tree.py +553 -0
  12. openretailscience/assets/fonts/Poppins-Bold.ttf +0 -0
  13. openretailscience/assets/fonts/Poppins-LightItalic.ttf +0 -0
  14. openretailscience/assets/fonts/Poppins-Medium.ttf +0 -0
  15. openretailscience/assets/fonts/Poppins-Regular.ttf +0 -0
  16. openretailscience/assets/fonts/Poppins-SemiBold.ttf +0 -0
  17. openretailscience/constants.py +295 -0
  18. openretailscience/metrics/__init__.py +0 -0
  19. openretailscience/metrics/base.py +29 -0
  20. openretailscience/metrics/distribution/__init__.py +0 -0
  21. openretailscience/metrics/distribution/acv.py +81 -0
  22. openretailscience/metrics/distribution/pct_of_stores.py +118 -0
  23. openretailscience/options.py +842 -0
  24. openretailscience/plots/__init__.py +0 -0
  25. openretailscience/plots/area.py +114 -0
  26. openretailscience/plots/bar.py +375 -0
  27. openretailscience/plots/broken_timeline.py +197 -0
  28. openretailscience/plots/cohort.py +94 -0
  29. openretailscience/plots/heatmap.py +134 -0
  30. openretailscience/plots/histogram.py +276 -0
  31. openretailscience/plots/index.py +505 -0
  32. openretailscience/plots/line.py +379 -0
  33. openretailscience/plots/period_on_period.py +144 -0
  34. openretailscience/plots/price.py +275 -0
  35. openretailscience/plots/scatter.py +382 -0
  36. openretailscience/plots/styles/__init__.py +1 -0
  37. openretailscience/plots/styles/colors.py +215 -0
  38. openretailscience/plots/styles/font_utils.py +62 -0
  39. openretailscience/plots/styles/graph_utils.py +834 -0
  40. openretailscience/plots/styles/styling_helpers.py +155 -0
  41. openretailscience/plots/time.py +160 -0
  42. openretailscience/plots/tree_diagram.py +813 -0
  43. openretailscience/plots/venn.py +127 -0
  44. openretailscience/plots/waterfall.py +217 -0
  45. openretailscience/plugin.py +238 -0
  46. openretailscience/segmentation/__init__.py +0 -0
  47. openretailscience/segmentation/hml.py +97 -0
  48. openretailscience/segmentation/nlr.py +218 -0
  49. openretailscience/segmentation/rfm.py +307 -0
  50. openretailscience/segmentation/segstats.py +1177 -0
  51. openretailscience/segmentation/threshold.py +165 -0
  52. openretailscience/utils/__init__.py +0 -0
  53. openretailscience/utils/date.py +226 -0
  54. openretailscience/utils/filter_and_label.py +52 -0
  55. openretailscience/utils/label.py +79 -0
  56. openretailscience/utils/validation.py +45 -0
  57. openretailscience-0.45.0.dist-info/METADATA +364 -0
  58. openretailscience-0.45.0.dist-info/RECORD +60 -0
  59. openretailscience-0.45.0.dist-info/WHEEL +4 -0
  60. openretailscience-0.45.0.dist-info/licenses/LICENSE +93 -0
@@ -0,0 +1,842 @@
1
+ """This module provides a simplified implementation of a pandas-like options system.
2
+
3
+ It allows users to get, set, and reset various options that control the behavior
4
+ of data display and processing. The module also includes a context manager for
5
+ temporarily changing options.
6
+
7
+ Example:
8
+ >>> set_option('display.max_rows', 100)
9
+ >>> print(get_option('display.max_rows'))
10
+ 100
11
+ >>> with option_context('display.max_rows', 10):
12
+ ... print(get_option('display.max_rows'))
13
+ 10
14
+ >>> print(get_option('display.max_rows'))
15
+ 100
16
+
17
+ """
18
+
19
+ from collections.abc import Generator
20
+ from contextlib import contextmanager
21
+ from functools import lru_cache
22
+ from pathlib import Path
23
+
24
+ import toml
25
+
26
+ from openretailscience.constants import COLORS
27
+
28
+ OptionTypes = str | int | float | bool | list | dict | None
29
+
30
+
31
+ class Options:
32
+ """A class to manage configurable options."""
33
+
34
+ def __init__(self) -> None:
35
+ """Initializes the options with default values."""
36
+ self._options: dict[str, OptionTypes] = {
37
+ # Database columns
38
+ "column.customer_id": "customer_id",
39
+ "column.transaction_id": "transaction_id",
40
+ "column.transaction_date": "transaction_date",
41
+ "column.transaction_time": "transaction_time",
42
+ "column.product_id": "product_id",
43
+ "column.unit_quantity": "unit_quantity",
44
+ "column.unit_price": "unit_price",
45
+ "column.unit_spend": "unit_spend",
46
+ "column.unit_cost": "unit_cost",
47
+ "column.promo_unit_spend": "promo_unit_spend",
48
+ "column.promo_unit_quantity": "promo_unit_quantity",
49
+ "column.store_id": "store_id",
50
+ # Aggregation columns
51
+ "column.agg.customer_id": "customers",
52
+ "column.agg.transaction_id": "transactions",
53
+ "column.agg.product_id": "products",
54
+ "column.agg.unit_quantity": "units",
55
+ "column.agg.unit_price": "prices",
56
+ "column.agg.unit_spend": "spend",
57
+ "column.agg.unit_cost": "costs",
58
+ "column.agg.promo_unit_spend": "promo_spend",
59
+ "column.agg.promo_unit_quantity": "promo_units",
60
+ "column.agg.store_id": "stores",
61
+ # Calculated columns
62
+ "column.calc.price_per_unit": "price_per_unit",
63
+ "column.calc.units_per_transaction": "units_per_transaction",
64
+ "column.calc.spend_per_customer": "spend_per_customer",
65
+ "column.calc.spend_per_transaction": "spend_per_transaction",
66
+ "column.calc.transactions_per_customer": "transactions_per_customer",
67
+ "column.calc.price_elasticity": "price_elasticity",
68
+ "column.calc.frequency_elasticity": "frequency_elasticity",
69
+ # Abbreviation suffix
70
+ "column.suffix.count": "cnt",
71
+ "column.suffix.percent": "pct",
72
+ "column.suffix.difference": "diff",
73
+ "column.suffix.percent_difference": "pct_diff",
74
+ "column.suffix.contribution": "contrib",
75
+ "column.suffix.period_1": "p1",
76
+ "column.suffix.period_2": "p2",
77
+ "column.suffix.unknown_customer": "unknown",
78
+ "column.suffix.total": "total",
79
+ # Color palettes
80
+ "plot.color.mono_palette": [
81
+ COLORS["green"][500],
82
+ COLORS["green"][300],
83
+ COLORS["green"][700],
84
+ ],
85
+ "plot.color.multi_color_palette": [
86
+ COLORS[color][shade]
87
+ for shade in [500, 300, 700]
88
+ for color in ["green", "blue", "red", "orange", "yellow", "violet", "pink"]
89
+ ],
90
+ "plot.color.positive": COLORS["green"][500],
91
+ "plot.color.negative": COLORS["red"][500],
92
+ "plot.color.neutral": COLORS["gray"][500],
93
+ "plot.color.difference": COLORS["blue"][500],
94
+ "plot.color.context": COLORS["gray"][400],
95
+ "plot.color.primary": COLORS["green"][500],
96
+ "plot.color.heatmap": "green",
97
+ # Plot font options
98
+ "plot.font.title_font": "poppins_semi_bold",
99
+ "plot.font.label_font": "poppins_regular",
100
+ "plot.font.tick_font": "poppins_regular",
101
+ "plot.font.source_font": "poppins_light_italic",
102
+ "plot.font.data_label_font": "poppins_regular",
103
+ "plot.font.title_size": 20.0,
104
+ "plot.font.label_size": 12.0,
105
+ "plot.font.tick_size": 10.0,
106
+ "plot.font.source_size": 10.0,
107
+ "plot.font.data_label_size": 8.0,
108
+ # Plot spacing options
109
+ "plot.spacing.title_pad": 10,
110
+ "plot.spacing.x_label_pad": 10,
111
+ "plot.spacing.y_label_pad": 10,
112
+ # Plot style options
113
+ "plot.style.background_color": "white",
114
+ "plot.style.grid_color": "#DAD8D7",
115
+ "plot.style.grid_alpha": 0.5,
116
+ "plot.style.show_top_spine": False,
117
+ "plot.style.show_right_spine": False,
118
+ "plot.style.show_bottom_spine": True,
119
+ "plot.style.show_left_spine": True,
120
+ "plot.style.legend_bbox_to_anchor": [1.05, 1.0],
121
+ "plot.style.legend_loc": "upper left",
122
+ }
123
+ self._descriptions: dict[str, str] = {
124
+ # Database columns
125
+ "column.customer_id": "The name of the column containing customer IDs.",
126
+ "column.transaction_id": "The name of the column containing transaction IDs.",
127
+ "column.transaction_date": "The name of the column containing transaction dates.",
128
+ "column.transaction_time": "The name of the column containing transaction times.",
129
+ "column.product_id": "The name of the column containing product IDs.",
130
+ "column.unit_quantity": "The name of the column containing the number of units sold.",
131
+ "column.unit_price": "The name of the column containing the unit price of the product.",
132
+ "column.unit_spend": (
133
+ "The name of the column containing the total spend of the products in the transaction. "
134
+ "ie, unit_price * units"
135
+ ),
136
+ "column.unit_cost": (
137
+ "The name of the column containing the total cost of the products in the transaction. "
138
+ "ie, single unit cost * units"
139
+ ),
140
+ "column.promo_unit_spend": (
141
+ "The name of the column containing the total spend on promotion of the products in the transaction. "
142
+ "ie, promotional unit price * units"
143
+ ),
144
+ "column.promo_unit_quantity": "The name of the column containing the number of units sold on promotion.",
145
+ "column.store_id": "The name of the column containing store IDs of the transaction.",
146
+ # Aggregation columns
147
+ "column.agg.customer_id": "The name of the column containing the number of unique customers.",
148
+ "column.agg.transaction_id": "The name of the column containing the number of transactions.",
149
+ "column.agg.product_id": "The name of the column containing the number of unique products.",
150
+ "column.agg.unit_quantity": "The name of the column containing the total number of units sold.",
151
+ "column.agg.unit_price": "The name of the column containing the total unit price of products.",
152
+ "column.agg.unit_spend": (
153
+ "The name of the column containing the total spend of the units in the transaction."
154
+ ),
155
+ "column.agg.unit_cost": "The name of the column containing the total unit cost of products.",
156
+ "column.agg.promo_unit_spend": (
157
+ "The name of the column containing the total promotional spend of the units in the transaction."
158
+ ),
159
+ "column.agg.promo_unit_quantity": (
160
+ "The name of the column containing the total number of units sold on promotion."
161
+ ),
162
+ "column.agg.store_id": "The name of the column containing the number of unique stores.",
163
+ # Calculated columns
164
+ "column.calc.price_per_unit": "The name of the column containing the price per unit.",
165
+ "column.calc.units_per_transaction": "The name of the column containing the units per transaction.",
166
+ "column.calc.spend_per_customer": "The name of the column containing the spend per customer.",
167
+ "column.calc.spend_per_transaction": "The name of the column containing the spend per transaction.",
168
+ "column.calc.transactions_per_customer": "The name of the column containing the transactions per customer.",
169
+ "column.calc.price_elasticity": "The name of the column containing the price elasticity calculation.",
170
+ "column.calc.frequency_elasticity": "The name of the column containing the price frequency calculation.",
171
+ # Abbreviation suffixes
172
+ "column.suffix.count": "The suffix to use for count columns.",
173
+ "column.suffix.percent": "The suffix to use for percentage columns.",
174
+ "column.suffix.difference": "The suffix to use for difference columns.",
175
+ "column.suffix.percent_difference": "The suffix to use for percentage difference columns.",
176
+ "column.suffix.contribution": "The suffix to use for revenue contribution columns.",
177
+ "column.suffix.period_1": (
178
+ "The suffix to use for period 1 columns. Often this could represent last year for instance."
179
+ ),
180
+ "column.suffix.period_2": (
181
+ "The suffix to use for period 2 columns. Often this could represent this year for instance."
182
+ ),
183
+ "column.suffix.unknown_customer": "The suffix to use for unknown customer columns.",
184
+ "column.suffix.total": "The suffix to use for total columns.",
185
+ # Color options
186
+ "plot.color.mono_palette": (
187
+ "Monochromatic palette for plots with few series (default: 3 green shades). Set to [] to disable."
188
+ ),
189
+ "plot.color.multi_color_palette": (
190
+ "Multi-color palette for plots with many series (default: 21 Tailwind colors)"
191
+ ),
192
+ "plot.color.positive": "Color for positive values (e.g., gains, increases)",
193
+ "plot.color.negative": "Color for negative values (e.g., losses, decreases)",
194
+ "plot.color.neutral": "Color for neutral values (e.g., net amounts)",
195
+ "plot.color.difference": "Color for difference values (e.g., waterfall transitions, tree differences)",
196
+ "plot.color.context": "Color for de-emphasized context lines",
197
+ "plot.color.primary": "Default color for single-series plots",
198
+ "plot.color.heatmap": (
199
+ "Tailwind color name (e.g., 'green', 'blue') or matplotlib colormap name"
200
+ " (e.g., 'Greens', 'viridis') for heatmaps"
201
+ ),
202
+ # Plot font descriptions
203
+ "plot.font.title_font": "The font family to use for plot titles.",
204
+ "plot.font.label_font": "The font family to use for axis labels.",
205
+ "plot.font.tick_font": "The font family to use for axis tick labels.",
206
+ "plot.font.source_font": "The font family to use for source text.",
207
+ "plot.font.data_label_font": "The font family to use for data point labels.",
208
+ "plot.font.title_size": "The font size for plot titles (in points).",
209
+ "plot.font.label_size": "The font size for axis labels (in points).",
210
+ "plot.font.tick_size": "The font size for axis tick labels (in points).",
211
+ "plot.font.source_size": "The font size for source text (in points).",
212
+ "plot.font.data_label_size": "The font size for data point labels (in points).",
213
+ # Plot spacing descriptions
214
+ "plot.spacing.title_pad": "The padding above the title (in points).",
215
+ "plot.spacing.x_label_pad": "The padding below the x-axis label (in points).",
216
+ "plot.spacing.y_label_pad": "The padding to the left of the y-axis label (in points).",
217
+ # Plot style descriptions
218
+ "plot.style.background_color": "The background color of the plot area.",
219
+ "plot.style.grid_color": "The color of the grid lines.",
220
+ "plot.style.grid_alpha": "The transparency of the grid lines (0.0 to 1.0).",
221
+ "plot.style.show_top_spine": "Whether to show the top border of the plot.",
222
+ "plot.style.show_right_spine": "Whether to show the right border of the plot.",
223
+ "plot.style.show_bottom_spine": "Whether to show the bottom border of the plot.",
224
+ "plot.style.show_left_spine": "Whether to show the left border of the plot.",
225
+ "plot.style.legend_bbox_to_anchor": "The bounding box anchor for legend positioning when moved outside.",
226
+ "plot.style.legend_loc": "The location of the legend when moved outside the plot.",
227
+ }
228
+ self._default_options: dict[str, OptionTypes] = self._options.copy()
229
+
230
+ def set_option(self, pat: str, val: OptionTypes) -> None:
231
+ """Set the value of the specified option.
232
+
233
+ Args:
234
+ pat: The option name.
235
+ val: The value to set the option to.
236
+
237
+ Raises:
238
+ ValueError: If the option name is unknown.
239
+ """
240
+ if pat not in self._options:
241
+ msg = f"Unknown option: {pat}"
242
+ raise ValueError(msg)
243
+
244
+ self._options[pat] = val
245
+
246
+ def get_option(self, pat: str) -> OptionTypes:
247
+ """Get the value of the specified option.
248
+
249
+ Args:
250
+ pat: The option name.
251
+
252
+ Returns:
253
+ The value of the option.
254
+
255
+ Raises:
256
+ ValueError: If the option name is unknown.
257
+ """
258
+ if pat in self._options:
259
+ return self._options[pat]
260
+
261
+ msg = f"Unknown option: {pat}"
262
+ raise ValueError(msg)
263
+
264
+ def reset_option(self, pat: str) -> None:
265
+ """Reset the specified option to its default value.
266
+
267
+ Args:
268
+ pat: The option name.
269
+
270
+ Raises:
271
+ ValueError: If the option name is unknown.
272
+ """
273
+ if pat not in self._options:
274
+ msg = f"Unknown option: {pat}"
275
+ raise ValueError(msg)
276
+
277
+ self._options[pat] = self._default_options[pat]
278
+
279
+ def list_options(self) -> list[str]:
280
+ """List all available options.
281
+
282
+ Returns:
283
+ A list of all option names.
284
+ """
285
+ return list(self._options.keys())
286
+
287
+ def describe_option(self, pat: str) -> str:
288
+ """Describe the specified option.
289
+
290
+ Args:
291
+ pat: The option name.
292
+
293
+ Returns:
294
+ A string describing the option and its current value.
295
+
296
+ Raises:
297
+ ValueError: If the option name is unknown.
298
+ """
299
+ if pat in self._descriptions:
300
+ return f"{pat}: {self._descriptions[pat]} (current value: {self._options[pat]})"
301
+
302
+ msg = f"Unknown option: {pat}"
303
+ raise ValueError(msg)
304
+
305
+ @staticmethod
306
+ def flatten_options(k: str, v: OptionTypes, parent_key: str = "") -> dict[str, OptionTypes]:
307
+ """Flatten nested options into a single dictionary."""
308
+ if parent_key != "":
309
+ parent_key += "."
310
+
311
+ if isinstance(v, dict):
312
+ ret_dict = {}
313
+ for sub_key, sub_value in v.items():
314
+ ret_dict.update(Options.flatten_options(sub_key, sub_value, parent_key=f"{parent_key}{k}"))
315
+ return ret_dict
316
+
317
+ return {f"{parent_key}{k}": v}
318
+
319
+ @classmethod
320
+ def load_from_project(cls) -> "Options":
321
+ """Try to load options from an openretailscience.toml file in the project root directory.
322
+
323
+ If the project root directory cannot be found, return a default Options instance.
324
+
325
+ Returns:
326
+ An Options instance with options loaded from the openretailscience.toml file or default
327
+ """
328
+ options_instance = cls()
329
+
330
+ project_root = find_project_root()
331
+ if project_root is None:
332
+ return options_instance
333
+
334
+ toml_file = Path(project_root) / "openretailscience.toml"
335
+ if toml_file.is_file():
336
+ return Options.load_from_toml(toml_file)
337
+
338
+ return options_instance
339
+
340
+ @classmethod
341
+ def load_from_toml(cls, file_path: str) -> "Options":
342
+ """Load options from a TOML file.
343
+
344
+ Args:
345
+ file_path: The path to the TOML file.
346
+
347
+ Raises:
348
+ ValueError: If the TOML file contains unknown options.
349
+ """
350
+ options_instance = cls()
351
+
352
+ with open(file_path) as f:
353
+ toml_data = toml.load(f)
354
+
355
+ for section, options in toml_data.items():
356
+ for option_name, option_value in Options.flatten_options(section, options).items():
357
+ if option_name in options_instance._options:
358
+ options_instance.set_option(option_name, option_value)
359
+ else:
360
+ msg = f"Unknown option in TOML file: {option_name}"
361
+ raise ValueError(msg)
362
+
363
+ return options_instance
364
+
365
+
366
+ @lru_cache
367
+ def find_project_root() -> str | None:
368
+ """Returns the directory containing .git, .hg, or pyproject.toml, starting from the current working directory."""
369
+ current_dir = Path.cwd()
370
+
371
+ while True:
372
+ if (Path(current_dir / ".git")).is_dir() or (Path(current_dir / "openretailscience.toml")).is_file():
373
+ return current_dir
374
+
375
+ parent_dir = Path(current_dir).parent
376
+ reached_root = parent_dir == current_dir
377
+ if reached_root:
378
+ return None
379
+
380
+ current_dir = parent_dir
381
+
382
+
383
+ # Global instance of Options
384
+ _global_options = Options().load_from_project()
385
+
386
+
387
+ def set_option(pat: str, val: OptionTypes) -> None:
388
+ """Set the value of the specified option.
389
+
390
+ This is a global function that delegates to the _global_options instance.
391
+
392
+ Args:
393
+ pat: The option name.
394
+ val: The value to set the option to.
395
+
396
+ Raises:
397
+ ValueError: If the option name is unknown.
398
+ """
399
+ _global_options.set_option(pat, val)
400
+
401
+
402
+ def get_option(pat: str) -> OptionTypes:
403
+ """Get the value of the specified option.
404
+
405
+ This is a global function that delegates to the _global_options instance.
406
+
407
+ Args:
408
+ pat: The option name.
409
+
410
+ Returns:
411
+ The value of the option.
412
+
413
+ Raises:
414
+ ValueError: If the option name is unknown.
415
+ """
416
+ return _global_options.get_option(pat)
417
+
418
+
419
+ def reset_option(pat: str) -> None:
420
+ """Reset the specified option to its default value.
421
+
422
+ This is a global function that delegates to the _global_options instance.
423
+
424
+ Args:
425
+ pat: The option name.
426
+
427
+ Raises:
428
+ ValueError: If the option name is unknown.
429
+ """
430
+ _global_options.reset_option(pat)
431
+
432
+
433
+ def list_options() -> list[str]:
434
+ """List all available options.
435
+
436
+ This is a global function that delegates to the _global_options instance.
437
+
438
+ Returns:
439
+ A list of all option names.
440
+ """
441
+ return _global_options.list_options()
442
+
443
+
444
+ def describe_option(pat: str) -> str:
445
+ """Describe the specified option.
446
+
447
+ This is a global function that delegates to the _global_options instance.
448
+
449
+ Args:
450
+ pat: The option name.
451
+
452
+ Returns:
453
+ A string describing the option and its current value.
454
+
455
+ Raises:
456
+ ValueError: If the option name is unknown.
457
+ """
458
+ return _global_options.describe_option(pat)
459
+
460
+
461
+ @contextmanager
462
+ def option_context(*args: OptionTypes) -> Generator[None, None, None]:
463
+ """Context manager to temporarily set options.
464
+
465
+ Temporarily set options and restore them to their previous values after the
466
+ context exits. The arguments should be supplied as alternating option names
467
+ and values.
468
+
469
+ Args:
470
+ *args: An even number of arguments, alternating between option names (str)
471
+ and their corresponding values.
472
+
473
+ Yields:
474
+ None
475
+
476
+ Raises:
477
+ ValueError: If an odd number of arguments is supplied.
478
+
479
+ Example:
480
+ >>> with option_context('display.max_rows', 10, 'display.max_columns', 5):
481
+ ... # Do something with modified options
482
+ ... pass
483
+ >>> # Options are restored to their previous values here
484
+ """
485
+ if len(args) % 2 != 0:
486
+ raise ValueError("The context manager requires an even number of arguments")
487
+
488
+ old_options: dict[str, OptionTypes] = {}
489
+ try:
490
+ for pat, val in zip(args[::2], args[1::2], strict=True):
491
+ old_options[pat] = get_option(pat)
492
+ set_option(pat, val)
493
+ yield
494
+ finally:
495
+ for pat, val in old_options.items():
496
+ set_option(pat, val)
497
+
498
+
499
+ class AggColumns:
500
+ """Aggregation columns accessed via cols.agg.*."""
501
+
502
+ def __init__(self) -> None:
503
+ """Initialize aggregation columns."""
504
+ # Base aggregation columns
505
+ self.customer_id: str = get_option("column.agg.customer_id")
506
+ self.transaction_id: str = get_option("column.agg.transaction_id")
507
+ self.unit_spend: str = get_option("column.agg.unit_spend")
508
+ self.unit_qty: str = get_option("column.agg.unit_quantity")
509
+ self.unit_cost: str = get_option("column.agg.unit_cost")
510
+ self.promo_unit_spend: str = get_option("column.agg.promo_unit_spend")
511
+ self.promo_unit_qty: str = get_option("column.agg.promo_unit_quantity")
512
+
513
+ # Period variants
514
+ self.customer_id_p1: str = ColumnHelper.join_options("column.agg.customer_id", "column.suffix.period_1")
515
+ self.customer_id_p2: str = ColumnHelper.join_options("column.agg.customer_id", "column.suffix.period_2")
516
+ self.transaction_id_p1: str = ColumnHelper.join_options("column.agg.transaction_id", "column.suffix.period_1")
517
+ self.transaction_id_p2: str = ColumnHelper.join_options("column.agg.transaction_id", "column.suffix.period_2")
518
+ self.unit_spend_p1: str = ColumnHelper.join_options("column.agg.unit_spend", "column.suffix.period_1")
519
+ self.unit_spend_p2: str = ColumnHelper.join_options("column.agg.unit_spend", "column.suffix.period_2")
520
+ self.unit_qty_p1: str = ColumnHelper.join_options("column.agg.unit_quantity", "column.suffix.period_1")
521
+ self.unit_qty_p2: str = ColumnHelper.join_options("column.agg.unit_quantity", "column.suffix.period_2")
522
+ self.unit_cost_p1: str = ColumnHelper.join_options("column.agg.unit_cost", "column.suffix.period_1")
523
+ self.unit_cost_p2: str = ColumnHelper.join_options("column.agg.unit_cost", "column.suffix.period_2")
524
+ self.promo_unit_spend_p1: str = ColumnHelper.join_options(
525
+ "column.agg.promo_unit_spend",
526
+ "column.suffix.period_1",
527
+ )
528
+ self.promo_unit_spend_p2: str = ColumnHelper.join_options(
529
+ "column.agg.promo_unit_spend",
530
+ "column.suffix.period_2",
531
+ )
532
+ self.promo_unit_qty_p1: str = ColumnHelper.join_options(
533
+ "column.agg.promo_unit_quantity",
534
+ "column.suffix.period_1",
535
+ )
536
+ self.promo_unit_qty_p2: str = ColumnHelper.join_options(
537
+ "column.agg.promo_unit_quantity",
538
+ "column.suffix.period_2",
539
+ )
540
+
541
+ # Diff variants
542
+ self.customer_id_diff: str = ColumnHelper.join_options("column.agg.customer_id", "column.suffix.difference")
543
+ self.transaction_id_diff: str = ColumnHelper.join_options(
544
+ "column.agg.transaction_id",
545
+ "column.suffix.difference",
546
+ )
547
+ self.unit_spend_diff: str = ColumnHelper.join_options("column.agg.unit_spend", "column.suffix.difference")
548
+ self.unit_qty_diff: str = ColumnHelper.join_options("column.agg.unit_quantity", "column.suffix.difference")
549
+ self.unit_cost_diff: str = ColumnHelper.join_options("column.agg.unit_cost", "column.suffix.difference")
550
+ self.promo_unit_spend_diff: str = ColumnHelper.join_options(
551
+ "column.agg.promo_unit_spend",
552
+ "column.suffix.difference",
553
+ )
554
+ self.promo_unit_qty_diff: str = ColumnHelper.join_options(
555
+ "column.agg.promo_unit_quantity",
556
+ "column.suffix.difference",
557
+ )
558
+
559
+ # Percent diff variants
560
+ self.customer_id_pct_diff: str = ColumnHelper.join_options(
561
+ "column.agg.customer_id",
562
+ "column.suffix.percent_difference",
563
+ )
564
+ self.transaction_id_pct_diff: str = ColumnHelper.join_options(
565
+ "column.agg.transaction_id",
566
+ "column.suffix.percent_difference",
567
+ )
568
+ self.unit_spend_pct_diff: str = ColumnHelper.join_options(
569
+ "column.agg.unit_spend",
570
+ "column.suffix.percent_difference",
571
+ )
572
+ self.unit_qty_pct_diff: str = ColumnHelper.join_options(
573
+ "column.agg.unit_quantity",
574
+ "column.suffix.percent_difference",
575
+ )
576
+ self.unit_cost_pct_diff: str = ColumnHelper.join_options(
577
+ "column.agg.unit_cost",
578
+ "column.suffix.percent_difference",
579
+ )
580
+ self.promo_unit_spend_pct_diff: str = ColumnHelper.join_options(
581
+ "column.agg.promo_unit_spend",
582
+ "column.suffix.percent_difference",
583
+ )
584
+ self.promo_unit_qty_pct_diff: str = ColumnHelper.join_options(
585
+ "column.agg.promo_unit_quantity",
586
+ "column.suffix.percent_difference",
587
+ )
588
+
589
+ # Contribution variants
590
+ self.customer_id_contrib: str = ColumnHelper.join_options(
591
+ "column.agg.customer_id",
592
+ "column.suffix.contribution",
593
+ )
594
+
595
+ # Percent variants
596
+ self.customers_pct: str = ColumnHelper.join_options("column.agg.customer_id", "column.suffix.percent")
597
+
598
+ # Unknown/Total variants
599
+ self.unit_spend_unknown: str = ColumnHelper.join_options(
600
+ "column.agg.unit_spend",
601
+ "column.suffix.unknown_customer",
602
+ )
603
+ self.unit_spend_total: str = ColumnHelper.join_options("column.agg.unit_spend", "column.suffix.total")
604
+ self.transaction_id_unknown: str = ColumnHelper.join_options(
605
+ "column.agg.transaction_id",
606
+ "column.suffix.unknown_customer",
607
+ )
608
+ self.transaction_id_total: str = ColumnHelper.join_options("column.agg.transaction_id", "column.suffix.total")
609
+ self.unit_qty_unknown: str = ColumnHelper.join_options(
610
+ "column.agg.unit_quantity",
611
+ "column.suffix.unknown_customer",
612
+ )
613
+ self.unit_qty_total: str = ColumnHelper.join_options("column.agg.unit_quantity", "column.suffix.total")
614
+
615
+
616
+ class CalcColumns:
617
+ """Calculated columns accessed via cols.calc.*."""
618
+
619
+ def __init__(self) -> None:
620
+ """Initialize calculated columns."""
621
+ # Base calculated columns
622
+ self.spend_per_cust: str = get_option("column.calc.spend_per_customer")
623
+ self.trans_per_cust: str = get_option("column.calc.transactions_per_customer")
624
+ self.spend_per_trans: str = get_option("column.calc.spend_per_transaction")
625
+ self.units_per_trans: str = get_option("column.calc.units_per_transaction")
626
+ self.price_per_unit: str = get_option("column.calc.price_per_unit")
627
+ self.price_elasticity: str = get_option("column.calc.price_elasticity")
628
+ self.frequency_elasticity: str = get_option("column.calc.frequency_elasticity")
629
+
630
+ # Period variants
631
+ self.spend_per_cust_p1: str = ColumnHelper.join_options(
632
+ "column.calc.spend_per_customer",
633
+ "column.suffix.period_1",
634
+ )
635
+ self.spend_per_cust_p2: str = ColumnHelper.join_options(
636
+ "column.calc.spend_per_customer",
637
+ "column.suffix.period_2",
638
+ )
639
+ self.trans_per_cust_p1: str = ColumnHelper.join_options(
640
+ "column.calc.transactions_per_customer",
641
+ "column.suffix.period_1",
642
+ )
643
+ self.trans_per_cust_p2: str = ColumnHelper.join_options(
644
+ "column.calc.transactions_per_customer",
645
+ "column.suffix.period_2",
646
+ )
647
+ self.spend_per_trans_p1: str = ColumnHelper.join_options(
648
+ "column.calc.spend_per_transaction",
649
+ "column.suffix.period_1",
650
+ )
651
+ self.spend_per_trans_p2: str = ColumnHelper.join_options(
652
+ "column.calc.spend_per_transaction",
653
+ "column.suffix.period_2",
654
+ )
655
+ self.units_per_trans_p1: str = ColumnHelper.join_options(
656
+ "column.calc.units_per_transaction",
657
+ "column.suffix.period_1",
658
+ )
659
+ self.units_per_trans_p2: str = ColumnHelper.join_options(
660
+ "column.calc.units_per_transaction",
661
+ "column.suffix.period_2",
662
+ )
663
+ self.price_per_unit_p1: str = ColumnHelper.join_options("column.calc.price_per_unit", "column.suffix.period_1")
664
+ self.price_per_unit_p2: str = ColumnHelper.join_options("column.calc.price_per_unit", "column.suffix.period_2")
665
+
666
+ # Diff variants
667
+ self.spend_per_cust_diff: str = ColumnHelper.join_options(
668
+ "column.calc.spend_per_customer",
669
+ "column.suffix.difference",
670
+ )
671
+ self.trans_per_cust_diff: str = ColumnHelper.join_options(
672
+ "column.calc.transactions_per_customer",
673
+ "column.suffix.difference",
674
+ )
675
+ self.spend_per_trans_diff: str = ColumnHelper.join_options(
676
+ "column.calc.spend_per_transaction",
677
+ "column.suffix.difference",
678
+ )
679
+ self.units_per_trans_diff: str = ColumnHelper.join_options(
680
+ "column.calc.units_per_transaction",
681
+ "column.suffix.difference",
682
+ )
683
+ self.price_per_unit_diff: str = ColumnHelper.join_options(
684
+ "column.calc.price_per_unit",
685
+ "column.suffix.difference",
686
+ )
687
+
688
+ # Percent diff variants
689
+ self.spend_per_cust_pct_diff: str = ColumnHelper.join_options(
690
+ "column.calc.spend_per_customer",
691
+ "column.suffix.percent_difference",
692
+ )
693
+ self.trans_per_cust_pct_diff: str = ColumnHelper.join_options(
694
+ "column.calc.transactions_per_customer",
695
+ "column.suffix.percent_difference",
696
+ )
697
+ self.spend_per_trans_pct_diff: str = ColumnHelper.join_options(
698
+ "column.calc.spend_per_transaction",
699
+ "column.suffix.percent_difference",
700
+ )
701
+ self.units_per_trans_pct_diff: str = ColumnHelper.join_options(
702
+ "column.calc.units_per_transaction",
703
+ "column.suffix.percent_difference",
704
+ )
705
+ self.price_per_unit_pct_diff: str = ColumnHelper.join_options(
706
+ "column.calc.price_per_unit",
707
+ "column.suffix.percent_difference",
708
+ )
709
+
710
+ # Contribution variants
711
+ self.spend_per_cust_contrib: str = ColumnHelper.join_options(
712
+ "column.calc.spend_per_customer",
713
+ "column.suffix.contribution",
714
+ )
715
+ self.trans_per_cust_contrib: str = ColumnHelper.join_options(
716
+ "column.calc.transactions_per_customer",
717
+ "column.suffix.contribution",
718
+ )
719
+ self.spend_per_trans_contrib: str = ColumnHelper.join_options(
720
+ "column.calc.spend_per_transaction",
721
+ "column.suffix.contribution",
722
+ )
723
+ self.units_per_trans_contrib: str = ColumnHelper.join_options(
724
+ "column.calc.units_per_transaction",
725
+ "column.suffix.contribution",
726
+ )
727
+ self.price_per_unit_contrib: str = ColumnHelper.join_options(
728
+ "column.calc.price_per_unit",
729
+ "column.suffix.contribution",
730
+ )
731
+
732
+ # Unknown/Total variants
733
+ self.spend_per_trans_unknown: str = ColumnHelper.join_options(
734
+ "column.calc.spend_per_transaction",
735
+ "column.suffix.unknown_customer",
736
+ )
737
+ self.spend_per_trans_total: str = ColumnHelper.join_options(
738
+ "column.calc.spend_per_transaction",
739
+ "column.suffix.total",
740
+ )
741
+ self.price_per_unit_unknown: str = ColumnHelper.join_options(
742
+ "column.calc.price_per_unit",
743
+ "column.suffix.unknown_customer",
744
+ )
745
+ self.price_per_unit_total: str = ColumnHelper.join_options("column.calc.price_per_unit", "column.suffix.total")
746
+ self.units_per_trans_unknown: str = ColumnHelper.join_options(
747
+ "column.calc.units_per_transaction",
748
+ "column.suffix.unknown_customer",
749
+ )
750
+ self.units_per_trans_total: str = ColumnHelper.join_options(
751
+ "column.calc.units_per_transaction",
752
+ "column.suffix.total",
753
+ )
754
+
755
+
756
+ class ColumnHelper:
757
+ """A class to help with column naming conventions.
758
+
759
+ Access patterns:
760
+ - Base columns: cols.transaction_date, cols.customer_id, etc.
761
+ - Aggregation columns: cols.agg.unit_spend, cols.agg.customer_id_p1, etc.
762
+ - Calculated columns: cols.calc.spend_per_cust, cols.calc.price_per_unit_diff, etc.
763
+ """
764
+
765
+ def __init__(self) -> None:
766
+ """Initialize column helper with base columns and nested column groups."""
767
+ # Base columns (stay flat on ColumnHelper)
768
+ self.transaction_date = get_option("column.transaction_date")
769
+ self.transaction_time = get_option("column.transaction_time")
770
+ self.customer_id = get_option("column.customer_id")
771
+ self.transaction_id = get_option("column.transaction_id")
772
+ self.product_id = get_option("column.product_id")
773
+ self.store_id = get_option("column.store_id")
774
+ self.unit_spend = get_option("column.unit_spend")
775
+ self.unit_qty = get_option("column.unit_quantity")
776
+ self.unit_cost = get_option("column.unit_cost")
777
+ self.promo_unit_spend = get_option("column.promo_unit_spend")
778
+ self.promo_unit_qty = get_option("column.promo_unit_quantity")
779
+
780
+ # Nested column groups
781
+ self.agg = AggColumns()
782
+ self.calc = CalcColumns()
783
+
784
+ @staticmethod
785
+ def join_options(*args: str, sep: str = "_") -> str:
786
+ """Join multiple option values together with a separator.
787
+
788
+ This method resolves option keys to their configured values and joins them.
789
+ Commonly used to create column names with suffixes like period indicators.
790
+
791
+ Args:
792
+ *args: Option keys to resolve and join (e.g., "column.agg.unit_spend", "column.suffix.period_1")
793
+ sep: Separator to use when joining values (default: "_")
794
+
795
+ Returns:
796
+ A string with all resolved option values joined together.
797
+
798
+ Example:
799
+ >>> join_options("column.agg.unit_spend", "column.suffix.period_1")
800
+ "spend_p1" # Assuming default options
801
+ """
802
+ return sep.join(map(get_option, args))
803
+
804
+
805
+ class PlotStyleHelper:
806
+ """A class to help with plot styling options.
807
+
808
+ Access patterns:
809
+ - Font options: style.title_font, style.title_size, etc.
810
+ - Spacing options: style.title_pad, style.x_label_pad, etc.
811
+ - Style options: style.background_color, style.grid_alpha, etc.
812
+ """
813
+
814
+ def __init__(self) -> None:
815
+ """Initialize plot style helper with font, spacing, and style options."""
816
+ # Font options
817
+ self.title_font: str = get_option("plot.font.title_font")
818
+ self.label_font: str = get_option("plot.font.label_font")
819
+ self.tick_font: str = get_option("plot.font.tick_font")
820
+ self.source_font: str = get_option("plot.font.source_font")
821
+ self.data_label_font: str = get_option("plot.font.data_label_font")
822
+ self.title_size: float = get_option("plot.font.title_size")
823
+ self.label_size: float = get_option("plot.font.label_size")
824
+ self.tick_size: float = get_option("plot.font.tick_size")
825
+ self.source_size: float = get_option("plot.font.source_size")
826
+ self.data_label_size: float = get_option("plot.font.data_label_size")
827
+
828
+ # Spacing options
829
+ self.title_pad: int = get_option("plot.spacing.title_pad")
830
+ self.x_label_pad: int = get_option("plot.spacing.x_label_pad")
831
+ self.y_label_pad: int = get_option("plot.spacing.y_label_pad")
832
+
833
+ # Style options
834
+ self.background_color: str = get_option("plot.style.background_color")
835
+ self.grid_color: str = get_option("plot.style.grid_color")
836
+ self.grid_alpha: float = get_option("plot.style.grid_alpha")
837
+ self.show_top_spine: bool = get_option("plot.style.show_top_spine")
838
+ self.show_right_spine: bool = get_option("plot.style.show_right_spine")
839
+ self.show_bottom_spine: bool = get_option("plot.style.show_bottom_spine")
840
+ self.show_left_spine: bool = get_option("plot.style.show_left_spine")
841
+ self.legend_bbox_to_anchor: list = get_option("plot.style.legend_bbox_to_anchor")
842
+ self.legend_loc: str = get_option("plot.style.legend_loc")