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,379 @@
1
+ """This module provides flexible functionality for creating line plots from pandas DataFrames.
2
+
3
+ It focuses on visualizing sequences that are ordered or sequential but not necessarily categorical, such as "days since
4
+ an event" or "months since a competitor opened." However, while this module can handle datetime values on the x-axis,
5
+ the **plots.time_line** module has additional features that make working with datetimes easier,
6
+ such as easily resampling the data to alternate time frames.
7
+
8
+ The sequences used in this module can include values like "days since an event" (e.g.,
9
+ -2, -1, 0, 1, 2) or "months since a competitor store opened." **This module is not intended
10
+ for use with actual datetime values**.
11
+
12
+ ### Core Features
13
+
14
+ - **Plotting Sequences or Indexes**: Plot one or more value columns (**`value_col`**) with
15
+ support for sequences like -2, -1, 0, 1, 2 (e.g., months since an event), using either the
16
+ index or a specified x-axis column (**`x_col`**).
17
+ - **Custom X-Axis or Index**: Use any column as the x-axis (**`x_col`**) or plot based on the
18
+ index if no x-axis column is specified.
19
+ - **Multiple Lines**: Create separate lines for each unique value in **`group_col`** (e.g.,
20
+ categories or product types).
21
+ - **Comprehensive Customization**: Easily customize plot titles, axis labels, and legends, with
22
+ the option to move the legend outside the plot.
23
+ - **Pre-Aggregated Data**: The data must be pre-aggregated before plotting, as no aggregation
24
+ occurs within the module.
25
+
26
+ ### Use Cases
27
+
28
+ - **Daily Trends**: Plot trends such as daily revenue or user activity, for example, tracking
29
+ revenue since the start of the year.
30
+ - **Event Impact**: Visualize how metrics (e.g., revenue, sales, or traffic) change before and
31
+ after an important event, such as a competitor store opening or a product launch.
32
+ - **Category Comparison**: Compare metrics across multiple categories over time, for example,
33
+ tracking total revenue for the top categories before and after an event like the introduction
34
+ of a new competitor.
35
+
36
+ ### Limitations and Handling of Temporal Data
37
+
38
+ - **Limited Handling of Temporal Data**: This module can plot simple time-based sequences, such
39
+ as "days since an event," but it cannot manipulate or directly handle datetime or date-like
40
+ columns. It is not optimized for actual datetime values. If a datetime column is passed or more
41
+ complex temporal plotting is needed, consider using the **`plots.time_line`** module, which is
42
+ specifically designed for working with temporal data and performing time-based manipulation.
43
+ - **Pre-Aggregated Data Required**: The module does not perform any data aggregation, so all
44
+ data must be pre-aggregated before being passed in for plotting.
45
+
46
+ """
47
+
48
+ import pandas as pd
49
+ from matplotlib.axes import Axes, SubplotBase
50
+
51
+ import openretailscience.plots.styles.graph_utils as gu
52
+ from openretailscience.plots.styles.colors import get_named_color, get_plot_colors
53
+
54
+
55
+ def _validate_and_prepare_input(
56
+ df: pd.DataFrame | pd.Series,
57
+ value_col: str | list[str] | None,
58
+ x_col: str | None,
59
+ group_col: str | None,
60
+ ) -> tuple[pd.DataFrame, str | list[str]]:
61
+ """Validate input parameters and convert Series to DataFrame if needed."""
62
+ # Handle Series input
63
+ if isinstance(df, pd.Series):
64
+ if value_col is not None:
65
+ raise ValueError(
66
+ "When df is a pd.Series, value_col must be None. The Series itself represents the values to plot.",
67
+ )
68
+ if x_col is not None:
69
+ raise ValueError(
70
+ "When df is a pd.Series, x_col must be None. The Series index is used as the x-axis.",
71
+ )
72
+ if group_col is not None:
73
+ raise ValueError(
74
+ "When df is a pd.Series, group_col must be None. Cannot group a single series.",
75
+ )
76
+ # Convert Series to DataFrame for uniform processing
77
+ series_name = df.name if df.name is not None else "value"
78
+ df = df.to_frame(name=series_name)
79
+ value_col = series_name
80
+
81
+ # Validate value_col for DataFrame input
82
+ if value_col is None:
83
+ raise ValueError("value_col is required when df is a DataFrame")
84
+
85
+ if isinstance(value_col, list) and group_col:
86
+ raise ValueError("Cannot use both a list for `value_col` and a `group_col`. Choose one.")
87
+
88
+ return df, value_col
89
+
90
+
91
+ def _validate_highlight_parameter(
92
+ highlight: str | list[str] | None,
93
+ value_col: str | list[str],
94
+ group_col: str | None,
95
+ pivot_df: pd.DataFrame | None = None,
96
+ ) -> list[str] | None:
97
+ """Validate and normalize the highlight parameter against available columns."""
98
+ if highlight is None:
99
+ return None
100
+
101
+ # Convert single string to list for uniform handling
102
+ if isinstance(highlight, str):
103
+ highlight = [highlight]
104
+
105
+ # Check if this is a single-line plot
106
+ is_single_line = group_col is None and (
107
+ isinstance(value_col, str) or (isinstance(value_col, list) and len(value_col) == 1)
108
+ )
109
+ if is_single_line:
110
+ raise ValueError("highlight parameter cannot be used with single-line plots")
111
+
112
+ # Validate highlight values against available columns if pivot_df is provided
113
+ if pivot_df is not None:
114
+ available_columns = list(pivot_df.columns)
115
+ invalid_highlights = [h for h in highlight if h not in available_columns]
116
+ if invalid_highlights:
117
+ error_msg = f"highlight values {invalid_highlights} not found in available columns {available_columns}"
118
+ raise ValueError(error_msg)
119
+
120
+ return highlight
121
+
122
+
123
+ def _create_pivot_dataframe(
124
+ df: pd.DataFrame,
125
+ value_col: str | list[str],
126
+ x_col: str | None,
127
+ group_col: str | None,
128
+ fill_na_value: float | None,
129
+ ) -> pd.DataFrame:
130
+ """Create pivot DataFrame for plotting."""
131
+ if group_col is None:
132
+ pivot_df = df.set_index(x_col if x_col is not None else df.index)[
133
+ [value_col] if isinstance(value_col, str) else value_col
134
+ ]
135
+ else:
136
+ pivot_df = (
137
+ df.pivot(columns=group_col, values=value_col)
138
+ if x_col is None
139
+ else df.pivot(index=x_col, columns=group_col, values=value_col)
140
+ )
141
+ if fill_na_value is not None:
142
+ pivot_df = pivot_df.fillna(fill_na_value)
143
+
144
+ return pivot_df
145
+
146
+
147
+ def _categorize_columns(
148
+ pivot_df: pd.DataFrame,
149
+ highlight: list[str] | None,
150
+ ) -> tuple[list[str], list[str]]:
151
+ """Categorize columns into highlighted and context groups."""
152
+ if highlight is not None:
153
+ highlighted_cols = [col for col in pivot_df.columns if col in highlight]
154
+ context_cols = [col for col in pivot_df.columns if col not in highlight]
155
+ else:
156
+ highlighted_cols = list(pivot_df.columns)
157
+ context_cols = []
158
+
159
+ return highlighted_cols, context_cols
160
+
161
+
162
+ def _generate_colors(highlighted_cols: list[str]) -> list[str]:
163
+ """Generate colors for highlighted lines."""
164
+ num_highlighted = len(highlighted_cols) if highlighted_cols else 1
165
+ return get_plot_colors(num_highlighted)
166
+
167
+
168
+ def _render_plot(
169
+ pivot_df: pd.DataFrame,
170
+ highlighted_cols: list[str],
171
+ context_cols: list[str],
172
+ highlighted_colors: list[str],
173
+ is_multi_line: bool,
174
+ ax: Axes | None,
175
+ **kwargs: dict[str, any],
176
+ ) -> Axes:
177
+ """Render the actual plot with context and highlighted lines."""
178
+ # Context line styling
179
+ context_color = get_named_color("context")
180
+ context_alpha = 0.6
181
+ context_linewidth = 1.5
182
+
183
+ # Highlighted line styling
184
+ highlighted_linewidth = kwargs.pop("linewidth", 3)
185
+ highlighted_alpha = 1.0
186
+
187
+ # Plot context lines first (lower z-order)
188
+ if context_cols:
189
+ context_df = pivot_df[context_cols]
190
+ ax = context_df.plot(
191
+ ax=ax,
192
+ linewidth=context_linewidth,
193
+ color=context_color,
194
+ alpha=context_alpha,
195
+ legend=False,
196
+ zorder=1,
197
+ **{k: v for k, v in kwargs.items() if k not in ["color", "alpha", "zorder"]},
198
+ )
199
+
200
+ # Plot highlighted lines second (higher z-order)
201
+ if highlighted_cols:
202
+ highlighted_df = pivot_df[highlighted_cols]
203
+ ax = highlighted_df.plot(
204
+ ax=ax,
205
+ linewidth=highlighted_linewidth,
206
+ color=kwargs.pop("color", highlighted_colors),
207
+ alpha=highlighted_alpha,
208
+ legend=is_multi_line,
209
+ zorder=2,
210
+ **kwargs,
211
+ )
212
+ else:
213
+ # Handle case where only context lines exist (shouldn't happen due to validation)
214
+ ax = pivot_df.plot(
215
+ ax=ax,
216
+ linewidth=highlighted_linewidth,
217
+ color=kwargs.pop("color", highlighted_colors),
218
+ alpha=highlighted_alpha,
219
+ legend=is_multi_line,
220
+ **kwargs,
221
+ )
222
+
223
+ return ax
224
+
225
+
226
+ def _apply_final_styling(
227
+ ax: Axes,
228
+ title: str | None,
229
+ x_label: str | None,
230
+ y_label: str | None,
231
+ legend_title: str | None,
232
+ move_legend_outside: bool,
233
+ source_text: str | None,
234
+ ) -> SubplotBase:
235
+ """Apply final styling and formatting to the plot."""
236
+ ax = gu.standard_graph_styles(
237
+ ax=ax,
238
+ title=title,
239
+ x_label=x_label,
240
+ y_label=y_label,
241
+ legend_title=legend_title,
242
+ move_legend_outside=move_legend_outside,
243
+ )
244
+
245
+ if source_text is not None:
246
+ gu.add_source_text(ax=ax, source_text=source_text)
247
+
248
+ return gu.standard_tick_styles(ax)
249
+
250
+
251
+ def plot(
252
+ df: pd.DataFrame | pd.Series,
253
+ value_col: str | list[str] | None = None,
254
+ x_label: str | None = None,
255
+ y_label: str | None = None,
256
+ title: str | None = None,
257
+ x_col: str | None = None,
258
+ group_col: str | None = None,
259
+ ax: Axes | None = None,
260
+ source_text: str | None = None,
261
+ legend_title: str | None = None,
262
+ move_legend_outside: bool = False,
263
+ fill_na_value: float | None = None,
264
+ highlight: str | list[str] | None = None,
265
+ **kwargs: dict[str, any],
266
+ ) -> SubplotBase:
267
+ """Plots `value_col` over `x_col` or index, with a separate line per unique `group_col` value.
268
+
269
+ This function supports both pandas DataFrames and Series as input. When a Series is provided,
270
+ the Series values are plotted against its index, and `value_col` must be None.
271
+
272
+ Args:
273
+ df (pd.DataFrame | pd.Series): The dataframe or series to plot. When a Series is provided,
274
+ it represents the values to plot against its index.
275
+ value_col (str | list[str], optional): The column(s) to plot. Must be None when df is a Series.
276
+ Required when df is a DataFrame.
277
+ x_label (str, optional): The x-axis label.
278
+ y_label (str, optional): The y-axis label.
279
+ title (str, optional): The title of the plot.
280
+ x_col (str, optional): The column to be used as the x-axis. If None, the index is used.
281
+ group_col (str, optional): The column used to define different lines.
282
+ legend_title (str, optional): The title of the legend.
283
+ ax (Axes, optional): Matplotlib axes object to plot on.
284
+ source_text (str, optional): The source text to add to the plot.
285
+ move_legend_outside (bool, optional): Move the legend outside the plot.
286
+ fill_na_value (float, optional): Value to fill NaNs with after pivoting.
287
+ highlight (str | list[str], optional): Line(s) to highlight. When using
288
+ `group_col`, these should be group values. When using a list of `value_col`,
289
+ these should be column names. Highlighted lines will be rendered with bold
290
+ linewidth (3), full opacity (alpha=1.0), and saturated colors. Non-highlighted
291
+ lines will be muted with gray color (#9ca3af), reduced opacity (alpha=0.6),
292
+ thinner linewidth (1.5), and rendered behind highlighted lines.
293
+ **kwargs: Additional keyword arguments for Pandas' `plot` function.
294
+
295
+ Returns:
296
+ SubplotBase: The matplotlib axes object.
297
+
298
+ Raises:
299
+ ValueError: If `value_col` is a list and `group_col` is provided (which causes ambiguity in plotting).
300
+ ValueError: If df is a Series and `value_col` is not None.
301
+ ValueError: If df is a DataFrame and `value_col` is None.
302
+ ValueError: If df is a Series and `x_col` is specified (Series uses its index as x-axis).
303
+ ValueError: If df is a Series and `group_col` is specified (cannot group a single series).
304
+ ValueError: If `highlight` is provided for single-line plot.
305
+ ValueError: If `highlight` values don't match available groups/columns.
306
+
307
+ Examples:
308
+ Highlighting specific product categories:
309
+
310
+ >>> import pandas as pd
311
+ >>> from openretailscience.plots import line
312
+ >>> df = pd.DataFrame({
313
+ ... "month": [1, 2, 3, 1, 2, 3, 1, 2, 3],
314
+ ... "category": ["Electronics", "Electronics", "Electronics",
315
+ ... "Clothing", "Clothing", "Clothing",
316
+ ... "Home", "Home", "Home"],
317
+ ... "revenue": [100, 120, 140, 80, 85, 90, 60, 65, 70]
318
+ ... })
319
+ >>> line.plot(
320
+ ... df=df,
321
+ ... x_col="month",
322
+ ... value_col="revenue",
323
+ ... group_col="category",
324
+ ... highlight=["Electronics", "Clothing"], # Home will be muted
325
+ ... title="Revenue by Category (Electronics & Clothing Highlighted)"
326
+ ... )
327
+
328
+ Highlighting specific value columns:
329
+
330
+ >>> df = pd.DataFrame({
331
+ ... "day": range(1, 6),
332
+ ... "revenue": [100, 110, 120, 130, 140],
333
+ ... "units_sold": [50, 55, 60, 65, 70],
334
+ ... "avg_order_value": [2.0, 2.0, 2.0, 2.0, 2.0],
335
+ ... "profit_margin": [0.2, 0.22, 0.24, 0.26, 0.28]
336
+ ... })
337
+ >>> line.plot(
338
+ ... df=df,
339
+ ... x_col="day",
340
+ ... value_col=["revenue", "units_sold", "avg_order_value", "profit_margin"],
341
+ ... highlight=["revenue", "profit_margin"], # Other metrics muted
342
+ ... title="Daily Metrics (Revenue & Profit Margin Highlighted)"
343
+ ... )
344
+
345
+ Single highlighted line:
346
+
347
+ >>> line.plot(
348
+ ... df=df,
349
+ ... x_col="month",
350
+ ... value_col="revenue",
351
+ ... group_col="category",
352
+ ... highlight="Electronics", # str is acceptable for single highlight
353
+ ... title="Revenue by Category (Electronics Highlighted)"
354
+ ... )
355
+ """
356
+ # Validate and prepare input
357
+ df, value_col = _validate_and_prepare_input(df, value_col, x_col, group_col)
358
+
359
+ # Validate highlight parameter (initial validation)
360
+ highlight = _validate_highlight_parameter(highlight, value_col, group_col)
361
+
362
+ # Create pivot DataFrame
363
+ pivot_df = _create_pivot_dataframe(df, value_col, x_col, group_col, fill_na_value)
364
+
365
+ # Validate highlight values against available columns
366
+ highlight = _validate_highlight_parameter(highlight, value_col, group_col, pivot_df)
367
+
368
+ # Categorize columns and generate colors
369
+ highlighted_cols, context_cols = _categorize_columns(pivot_df, highlight)
370
+ highlighted_colors = _generate_colors(highlighted_cols)
371
+
372
+ # Determine if multi-line plot
373
+ is_multi_line = (group_col is not None) or (isinstance(value_col, list) and len(value_col) > 1)
374
+
375
+ # Render the plot
376
+ ax = _render_plot(pivot_df, highlighted_cols, context_cols, highlighted_colors, is_multi_line, ax, **kwargs)
377
+
378
+ # Apply final styling
379
+ return _apply_final_styling(ax, title, x_label, y_label, legend_title, move_legend_outside, source_text)
@@ -0,0 +1,144 @@
1
+ """Period on period module.
2
+
3
+ This module provides functionality for plotting multiple overlapping time periods
4
+ from the same time series on a single line chart using matplotlib.
5
+
6
+ The `plot` function is useful for visual comparisons of temporal trends
7
+ across different time windows, with each time window plotted as a separate line
8
+ but aligned to a common starting point.
9
+
10
+ Example use case: Comparing sales data across multiple promotional weeks or seasonal periods.
11
+ """
12
+
13
+ from datetime import datetime
14
+
15
+ import matplotlib.pyplot as plt
16
+ import pandas as pd
17
+ from dateutil.relativedelta import relativedelta
18
+ from matplotlib.axes import Axes
19
+
20
+ import openretailscience.plots.styles.graph_utils as gu
21
+ from openretailscience.plots.line import plot as line_plot
22
+
23
+ LINE_STYLES = [
24
+ "-", # solid
25
+ "--", # dashed
26
+ ":", # dotted
27
+ "-.", # dashdot
28
+ (0, (5, 10)), # long dash with offset
29
+ (0, (6, 6)), # loosely dashed
30
+ (0, (3, 5, 1, 5)), # loosely dashdotted
31
+ (0, (3, 5, 1, 5, 1, 5)), # loosely dashdotdotted
32
+ ]
33
+
34
+
35
+ def plot(
36
+ df: pd.DataFrame,
37
+ x_col: str,
38
+ value_col: str,
39
+ periods: list[tuple[str | datetime, str | datetime]],
40
+ x_label: str | None = None,
41
+ y_label: str | None = None,
42
+ title: str | None = None,
43
+ source_text: str | None = None,
44
+ legend_title: str | None = None,
45
+ move_legend_outside: bool = False,
46
+ ax: Axes | None = None,
47
+ **kwargs: dict[str, any],
48
+ ) -> Axes:
49
+ """Plot multiple overlapping periods from a single time series as individual lines.
50
+
51
+ This function is used to align and overlay several time intervals from the same
52
+ dataset to facilitate visual comparison. Each period is realigned to the reference
53
+ start date and plotted as a separate line using a distinct linestyle.
54
+
55
+ Note:
56
+ The `periods` argument accepts a list of (start_date, end_date) tuples,
57
+ which define the time windows to overlay. Each element in the tuple can be either
58
+ a string (e.g., "2022-01-01") or a `datetime` object. You can use
59
+ `find_overlapping_periods` from `openretailscience.utils.date` to generate
60
+ the `periods` input automatically.
61
+
62
+ Args:
63
+ df (pd.DataFrame): Input DataFrame containing the time series data.
64
+ x_col (str): Name of the column representing datetime values.
65
+ value_col (str): Name of the column representing the y-axis values (e.g. sales, counts).
66
+ periods (List[Tuple[Union[str, datetime], Union[str, datetime]]]):
67
+ A list of (start_date, end_date) tuples representing the periods to plot.
68
+ x_label (Optional[str]): Custom label for the x-axis.
69
+ y_label (Optional[str]): Custom label for the y-axis.
70
+ title (Optional[str]): Title for the plot.
71
+ source_text (Optional[str]): Text to show below the plot as a data source.
72
+ legend_title (Optional[str]): Title for the plot legend.
73
+ move_legend_outside (bool): Whether to place the legend outside the plot area.
74
+ ax (Optional[Axes]): Matplotlib Axes object to draw on. If None, a new one is created.
75
+ **kwargs: Additional keyword arguments passed to the base line plot function.
76
+
77
+ Returns:
78
+ matplotlib.axes.Axes: The matplotlib Axes object with the completed plot.
79
+
80
+ Raises:
81
+ ValueError: The 'periods' list must contain at least two (start, end) tuples for comparison.
82
+ """
83
+ min_period_length = 2
84
+ if len(periods) < min_period_length:
85
+ raise ValueError("The 'periods' list must contain at least two (start, end) tuples for comparison")
86
+
87
+ periods = [(pd.to_datetime(start), pd.to_datetime(end)) for start, end in periods]
88
+ start_ref = periods[0][0]
89
+
90
+ sorted_periods = sorted(periods, reverse=True, key=lambda x: pd.to_datetime(x[0]))
91
+
92
+ ax = ax or plt.gca()
93
+
94
+ period_styles = {period: LINE_STYLES[idx % len(LINE_STYLES)] for idx, period in enumerate(sorted_periods)}
95
+
96
+ df[x_col] = pd.to_datetime(df[x_col])
97
+
98
+ start_ref_year = start_ref.year
99
+
100
+ for start_str, end_str in periods:
101
+ style = period_styles[(start_str, end_str)]
102
+ start = pd.to_datetime(start_str)
103
+ end = pd.to_datetime(end_str)
104
+ period_df = df[(df[x_col] >= start) & (df[x_col] <= end)].copy()
105
+
106
+ if period_df.empty:
107
+ continue
108
+
109
+ year_diff = start.year - start_ref_year
110
+
111
+ period_df["realigned_date"] = period_df[x_col].apply(
112
+ lambda d, year_diff=year_diff: d - relativedelta(years=year_diff),
113
+ )
114
+
115
+ label = f"{start_str.date()} to {end_str.date()}"
116
+ line_plot(
117
+ df=period_df,
118
+ x_col="realigned_date",
119
+ value_col=value_col,
120
+ ax=ax,
121
+ linestyle=style,
122
+ x_label=x_label,
123
+ y_label=y_label,
124
+ **kwargs,
125
+ )
126
+ line = ax.get_lines()[-1]
127
+ line.set_label(label)
128
+ line.set_linestyle(style)
129
+
130
+ ax = gu.standard_graph_styles(
131
+ ax=ax,
132
+ title=title,
133
+ x_label=x_label or x_col,
134
+ y_label=y_label or value_col,
135
+ legend_title=legend_title,
136
+ move_legend_outside=move_legend_outside,
137
+ )
138
+
139
+ ax = gu.standard_tick_styles(ax)
140
+
141
+ if source_text:
142
+ gu.add_source_text(ax=ax, source_text=source_text)
143
+
144
+ return ax