pytrendy 1.4.0.dev4__tar.gz → 1.4.0.dev5__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 (25) hide show
  1. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/PKG-INFO +1 -1
  2. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pyproject.toml +1 -1
  3. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/detect_trends.py +34 -13
  4. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/plot_pytrendy.py +144 -35
  5. pytrendy-1.4.0.dev5/pytrendy/io/prepare_index.py +216 -0
  6. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/results_pytrendy.py +25 -3
  7. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_analyse.py +1 -1
  8. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_get.py +9 -9
  9. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/__init__.py +2 -2
  10. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/abrupt_shaving.py +21 -23
  11. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/artifact_cleanup.py +69 -70
  12. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/gradual_expand_contract.py +23 -25
  13. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/segment_grouping.py +2 -2
  14. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/trend_classify.py +3 -3
  15. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/segments_refine/update_neighbours.py +23 -21
  16. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/process_signals.py +19 -15
  17. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/LICENSE +0 -0
  18. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/README.md +0 -0
  19. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/__init__.py +0 -0
  20. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/__init__.py +0 -0
  21. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/data/classes_signals.csv +0 -0
  22. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/data/series_synthetic.csv +0 -0
  23. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/io/data_loader.py +0 -0
  24. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/post_processing/__init__.py +0 -0
  25. {pytrendy-1.4.0.dev4 → pytrendy-1.4.0.dev5}/pytrendy/simpledtw.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pytrendy
3
- Version: 1.4.0.dev4
3
+ Version: 1.4.0.dev5
4
4
  Summary: Trend Detection in Python. Applicable for real-world industry use cases in time series.
5
5
  License: MIT License
6
6
 
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "pytrendy"
3
- version = "1.4.0.dev4"
3
+ version = "1.4.0.dev5"
4
4
  description = "Trend Detection in Python. Applicable for real-world industry use cases in time series."
5
5
  authors = [
6
6
  { name = "Russell Sammut Bonnici", email = "r.sammutbonnici@gmail.com" },
@@ -8,8 +8,17 @@ from .post_processing.segments_refine import refine_segments
8
8
  from .post_processing.segments_analyse import analyse_segments
9
9
  from .io.plot_pytrendy import plot_pytrendy
10
10
  from .io.results_pytrendy import PyTrendyResults
11
+ from .io import prepare_index
11
12
 
12
- def detect_trends(df: pd.DataFrame, date_col: str, value_col: str, plot=True, method_params: dict=None, plot_params: dict=None, debug: bool=False ) -> PyTrendyResults:
13
+
14
+ def detect_trends(df: pd.DataFrame,
15
+ value_col: str,
16
+ date_col: str|None=None,
17
+ plot: bool=True,
18
+ method_params: dict|None=None,
19
+ plot_params: dict|None=None,
20
+ debug: bool=False
21
+ ) -> PyTrendyResults:
13
22
  """
14
23
  This is the main function that runs trend detection end-to-end.
15
24
 
@@ -29,18 +38,17 @@ def detect_trends(df: pd.DataFrame, date_col: str, value_col: str, plot=True, me
29
38
  Args:
30
39
  df (pd.DataFrame):
31
40
  Input time series data containing at least the specified `date_col` and `value_col`.
32
- The `date_col` must contain datetime-like values (daily frequency recommended).
33
- date_col (str):
34
- Name of the column representing timestamps. This column is converted to datetime and set as the index.
35
41
  value_col (str):
36
42
  Name of the column containing the primary signal to analyse for trend detection.
43
+ date_col (str|None):
44
+ Column giving the x-axis position of each observation. Typically dates, but any unique, sortable values (integer, float, or string) are supported. If not specified, the DataFrame's index is used.
37
45
  plot (bool, optional):
38
46
  If `True`, generates a matplotlib plot showing the detected trend segments over the original signal.
39
47
  Defaults to `True`.
40
48
  method_params (dict, optional):
41
49
  Optional parameters to customize detection heuristics. Supported keys:
42
50
 
43
- - **abrupt_padding** (`int`): Number of days to pad around abrupt transitions. Defaults to `0`.
51
+ - **abrupt_padding** (`int`): Number of days to pad after abrupt transitions. Defaults to `0`.
44
52
  - **gradual_padding** (`int`): Number of days to pad after gradual trend ends. Defaults to `0`.
45
53
  - **avoid_noise** (`bool`): Whether to avoid noisy segments in trend detection. Defaults to `True`.
46
54
  plot_params (dict, optional):
@@ -64,11 +72,17 @@ def detect_trends(df: pd.DataFrame, date_col: str, value_col: str, plot=True, me
64
72
  An object encapsulating the detected segments and associated metadata.
65
73
  Use this object to access segment statistics, rankings, and export utilities.
66
74
  """
67
- df = df.copy()
68
- df[date_col] = pd.to_datetime(df[date_col])
69
- df.set_index(date_col, inplace=True)
70
- df = df[[value_col]]
71
-
75
+ # Reject the deprecated positional order detect_trends(df, date_col, value_col).
76
+ if date_col is not None and prepare_index.is_legacy_positional_order(df, value_col, date_col):
77
+ raise TypeError(
78
+ "detect_trends received arguments in the deprecated (date_col, value_col) order. "
79
+ "Pass value_col first: detect_trends(df, value_col, date_col=...)."
80
+ )
81
+
82
+ # Stage the DataFrame on an internal integer index, keeping the external index
83
+ # values and a lookup so boundaries can be remapped back later.
84
+ df, external_index, index_lookup, index_type = prepare_index.prepare_index(df, date_col, value_col)
85
+
72
86
  if method_params is None:
73
87
  method_params = {} # Avoid mutable default argument by accepting None and constructing a new dict here
74
88
 
@@ -93,7 +107,14 @@ def detect_trends(df: pd.DataFrame, date_col: str, value_col: str, plot=True, me
93
107
  segments = get_segments(df)
94
108
  segments = refine_segments(df, value_col, segments, method_params)
95
109
  segments = analyse_segments(df, value_col, segments)
96
- if plot: plot_pytrendy(df, value_col, segments, plot_params=plot_params)
97
110
 
98
- results = PyTrendyResults(segments)
99
- return results
111
+ # Translate internal segment boundaries back to the user's external index values.
112
+ segments = prepare_index.remap_boundaries(segments, index_lookup)
113
+
114
+ if plot:
115
+ # Restore the external index for plotting before rendering the segments.
116
+ plot_df = prepare_index.prepare_plot_frame(df, date_col, external_index, index_type)
117
+ plot_pytrendy(df=plot_df, value_col=value_col, segments_enhanced=segments, index_type=index_type, plot_params=plot_params)
118
+
119
+ results = PyTrendyResults(segments=segments, index_type=index_type)
120
+ return results
@@ -1,11 +1,30 @@
1
1
  """**Visualize Detected Trends Over Time Series**"""
2
2
 
3
3
  import pandas as pd
4
+ import numpy as np
4
5
  import matplotlib.pyplot as plt
5
6
  import matplotlib.dates as mdates
6
7
  import matplotlib.patches as mpatches
7
8
 
8
- def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict], suppress_show: bool = False, plot_params: dict = None) -> plt.Figure:
9
+
10
+ def _safe_adjacent(index, pos, offset):
11
+ """
12
+ Safely get an adjacent index value with bounds checking.
13
+
14
+ Args:
15
+ index: The index to access
16
+ pos: Current position in the index
17
+ offset: Offset from current position (+1 or -1)
18
+
19
+ Returns:
20
+ The adjacent index value if within bounds, None otherwise
21
+ """
22
+ new_pos = pos + offset
23
+ if 0 <= new_pos < len(index):
24
+ return index[new_pos]
25
+ return None
26
+
27
+ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict], index_type: str = "date", suppress_show: bool = False, plot_params: dict = None) -> plt.Figure:
9
28
  """
10
29
  Visualizes detected trend segments over the original time series signal.
11
30
 
@@ -19,6 +38,8 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
19
38
  Name of the column containing the signal to plot.
20
39
  segments_enhanced (list):
21
40
  List of segment dictionaries containing keys like `'start'`, `'end'`, `'direction'`, `'trend_class'`, and `'change_rank'`.
41
+ index_type (str):
42
+ The type of index passed by the user. Different index types require different logic. Currently Accepted Index Types are: "date", "integer", "float".
22
43
  suppress_show (bool, optional):
23
44
  If True, suppresses the automatic display of the plot with plt.show(). Defaults to False.
24
45
  plot_params (dict, optional):
@@ -78,16 +99,29 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
78
99
  # Plot the value line
79
100
  ax.plot(df.index, df[value_col], color='black', lw=1)
80
101
 
102
+
81
103
  # Add shaded regions with fill_between
82
104
  ymin, ymax = ax.get_ylim() # get plot's visible y-range
83
105
  for i, seg in enumerate(segments_enhanced):
84
- start = pd.to_datetime(seg['start'])
85
- end = pd.to_datetime(seg['end'])
106
+
107
+ if index_type == "date":
108
+ start = pd.to_datetime(seg['start'])
109
+ end = pd.to_datetime(seg['end'])
110
+ else:
111
+ start = seg['start']
112
+ end = seg['end']
113
+
86
114
  color = color_map.get(seg['direction'], 'gray')
87
115
 
88
116
  # Get context on prev seg if possible
89
117
  prev_seg = segments_enhanced[i-1] if i-1 >= 0 else None
90
- prev_neighbouring = prev_seg and (pd.to_datetime(prev_seg['end']) == (start - pd.Timedelta(days=1)))
118
+ if index_type == "date":
119
+ prev_neighbouring = prev_seg and (pd.to_datetime(prev_seg['end']) == (start - pd.Timedelta(days=1)))
120
+ elif index_type == 'string':
121
+ prev_neighbouring = prev_seg and (prev_seg['end'] == df.index[df.index.get_loc(start) - 1])
122
+ else:
123
+ prev_neighbouring = prev_seg and (prev_seg['end'] == (start - 1))
124
+
91
125
  is_prev_not_trend = prev_seg and (not ('trend_class' in prev_seg))
92
126
 
93
127
  # Current seg context
@@ -97,18 +131,32 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
97
131
 
98
132
  # Get context on next seg if possible
99
133
  next_seg = segments_enhanced[i+1] if i+1 < len(segments_enhanced) else None
100
- next_neighbouring = next_seg and (pd.to_datetime(next_seg['start']) == (end + pd.Timedelta(days=1)))
134
+ if index_type == 'date':
135
+ next_neighbouring = next_seg and (pd.to_datetime(next_seg['start']) == (end + pd.Timedelta(days=1)))
136
+ elif index_type == 'string':
137
+ end_pos = df.index.get_loc(end)
138
+ next_neighbouring = next_seg and (next_seg['start'] == _safe_adjacent(df.index, end_pos, 1))
139
+ else:
140
+ next_neighbouring = next_seg and (next_seg['start'] == (end + 1))
141
+
101
142
  next_seg_abrupt = next_seg and (('trend_class' in next_seg) and (next_seg['trend_class'] == 'abrupt'))
102
143
  next_seg_noise = next_seg and (next_seg['direction'] == 'Noise')
103
144
 
104
145
  # Adjust starts when appropriate
105
146
  if is_abrupt or is_noise:
106
- start = start # Conditional logic for making abrupt visually tighter
147
+ pass # Keep start as-is for abrupt/noise segments
107
148
  else:
108
- new_start = start - pd.Timedelta(days=1) # Everything else displaced left start
149
+ if index_type == 'date':
150
+ new_start = start - pd.Timedelta(days=1) # Everything else displaced left start
151
+ elif index_type == 'string':
152
+ start_pos = df.index.get_loc(start)
153
+ new_start = _safe_adjacent(df.index, start_pos, -1)
154
+ else:
155
+ new_start = start - 1 # Everything else displaced left start
109
156
 
110
157
  # Check validity of plot start adjustment
111
- value_new_start = df.loc[new_start, value_col] if new_start in df.index else None
158
+ value_new_start = df.loc[new_start, value_col] if new_start is not None and new_start in df.index else None
159
+
112
160
  value = df.loc[start, value_col]
113
161
 
114
162
  valid_up_start = (value_new_start) and (seg['direction'] == 'Up') and (value_new_start < value)
@@ -117,20 +165,38 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
117
165
  start = new_start # Apply left displacement only if valid
118
166
  else:
119
167
  # if not displaced and prev is not trend, adjust by plotting (as prev has already been drawn)
120
- if is_prev_not_trend and prev_neighbouring:
121
- prev_end = pd.to_datetime(segments_enhanced[i-1]['end'])
122
- prev_new_end = (prev_end + pd.Timedelta(days=1)).strftime('%Y-%m-%d')
123
- mask = (df.index >= prev_end) & (df.index <= prev_new_end)
124
- prev_color = color_map.get(segments_enhanced[i-1]['direction'], 'gray')
125
- ax.fill_between(df.index[mask], ymin, ymax, color=prev_color, alpha=default_params['alpha'])
126
-
168
+ if is_prev_not_trend and prev_neighbouring:
169
+ if index_type == 'date':
170
+ prev_end = pd.to_datetime(segments_enhanced[i-1]['end'])
171
+ prev_new_end = (prev_end + pd.Timedelta(days=1)).strftime('%Y-%m-%d')
172
+ elif index_type == 'string':
173
+ prev_end = segments_enhanced[i-1]['end']
174
+ prev_end_pos = df.index.get_loc(prev_end)
175
+ prev_new_end = _safe_adjacent(df.index, prev_end_pos, 1)
176
+ else:
177
+ prev_end = segments_enhanced[i-1]['end']
178
+ prev_new_end = prev_end + 1
179
+
180
+ if prev_new_end is not None:
181
+ if index_type == 'string':
182
+ mask = (np.arange(len(df)) >= df.index.get_loc(prev_end)) & (np.arange(len(df)) <= df.index.get_loc(prev_new_end))
183
+ else:
184
+ mask = (df.index >= prev_end) & (df.index <= prev_new_end)
185
+ prev_color = color_map.get(segments_enhanced[i-1]['direction'], 'gray')
186
+ ax.fill_between(df.index[mask], ymin, ymax, color=prev_color, alpha=default_params['alpha'])
127
187
 
128
188
  # Adjust ends when appropriate
129
189
  if (next_seg_abrupt or next_seg_noise) and next_neighbouring:
130
- new_end = end + pd.Timedelta(days=1)
190
+ if index_type == 'date':
191
+ new_end = end + pd.Timedelta(days=1)
192
+ elif index_type == 'string':
193
+ end_pos = df.index.get_loc(end)
194
+ new_end = _safe_adjacent(df.index, end_pos, 1)
195
+ else:
196
+ new_end = end + 1
131
197
 
132
198
  # Check validity of plot end adjustment
133
- value_new_end = df.loc[new_end, value_col] if new_end in df.index else None
199
+ value_new_end = df.loc[new_end, value_col] if new_end is not None and new_end in df.index else None
134
200
  value = df.loc[end, value_col]
135
201
 
136
202
  valid_up_end = (value_new_end) and (seg['direction'] == 'Up') and (value_new_end > value)
@@ -141,16 +207,34 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
141
207
  else:
142
208
  # if not displaced and next is noise, adjust for next plotting round
143
209
  if next_seg_noise and next_neighbouring:
144
- segments_enhanced[i+1]['start'] = (pd.to_datetime(segments_enhanced[i+1]['start']) - pd.Timedelta(days=1)).strftime('%Y-%m-%d')
210
+ if index_type == 'date':
211
+ segments_enhanced[i+1]['start'] = (pd.to_datetime(segments_enhanced[i+1]['start']) - pd.Timedelta(days=1)).strftime('%Y-%m-%d')
212
+ elif index_type == 'string':
213
+ next_start_pos = df.index.get_loc(segments_enhanced[i+1]['start'])
214
+ segments_enhanced[i+1]['start'] = _safe_adjacent(df.index, next_start_pos, -1)
215
+ else:
216
+ segments_enhanced[i+1]['start'] = (segments_enhanced[i+1]['start'] - 1)
145
217
  else:
146
- end = end
218
+ pass # Keep end as-is
219
+
220
+ if index_type == 'string':
221
+ mask = (np.arange(len(df)) >= df.index.get_loc(start)) & (np.arange(len(df)) <= df.index.get_loc(end))
222
+ else:
223
+ mask = (df.index >= start) & (df.index <= end)
224
+
147
225
 
148
- mask = (df.index >= start) & (df.index <= end)
149
226
  ax.fill_between(df.index[mask], ymin, ymax, color=color, alpha=default_params['alpha'])
150
227
 
151
228
  # Add ranking if up/down trend
152
229
  if 'change_rank' in seg and seg['direction'] in ['Up', 'Down']:
153
- mid_date = start + (end - start) / 2
230
+
231
+ if index_type in ['string']:
232
+ midpoint = int((df.index.get_loc(end) - df.index.get_loc(start))/2)
233
+ mid_date = df.index[df.index.get_loc(start) + midpoint]
234
+ else:
235
+ mid_date = start + (end - start) / 2
236
+
237
+
154
238
  y_pos = ymax - (ymax - ymin) * 0.05
155
239
  ax.text(mid_date, y_pos, str(seg['change_rank']), fontsize=12,
156
240
  fontweight='bold', ha='center', va='top',
@@ -158,35 +242,60 @@ def plot_pytrendy(df: pd.DataFrame, value_col: str, segments_enhanced: list[dict
158
242
 
159
243
  # Add vertical line if next seg is same & touching
160
244
  if next_seg and next_neighbouring and next_seg['direction'] == seg['direction']:
161
- line_date = pd.to_datetime(seg['end'])
245
+ if index_type == 'date':
246
+ line_date = pd.to_datetime(seg['end'])
247
+ else:
248
+ line_date = seg['end']
162
249
  ax.axvline(x=line_date, color=color[5:], linewidth=0.5)
163
250
 
164
251
  # Set limits
165
- first_date = df.index.min()
166
- last_date = df.index.max()
252
+ if index_type == 'string':
253
+ first_date = df.index[0]
254
+ last_date = df.index[-1]
255
+ else:
256
+ first_date = df.index.min()
257
+ last_date = df.index.max()
258
+
167
259
  ax.set_xlim(first_date, last_date)
168
260
  ax.set_ylim(ymin, ymax)
169
261
 
170
- # Major ticks: every 7 days (with labels)
171
- ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1))
172
- ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
262
+ if index_type == 'date':
263
+ # Major ticks: every 7 days (with labels)
264
+ ax.xaxis.set_major_locator(mdates.WeekdayLocator(interval=1))
265
+ ax.xaxis.set_major_formatter(mdates.DateFormatter('%Y-%m-%d'))
173
266
 
174
- # Minor ticks: every day (no labels, just tick marks/grid)
175
- ax.xaxis.set_minor_locator(mdates.DayLocator())
267
+ # Minor ticks: every day (no labels, just tick marks/grid)
268
+ ax.xaxis.set_minor_locator(mdates.DayLocator())
176
269
 
177
270
  # Rotate major tick labels
178
271
  plt.setp(ax.get_xticklabels(), rotation=90, ha='right')
179
272
 
180
273
  # Optional: show grid lines for both
181
- grid_params = default_params['grid']
182
- if grid_params['visible']:
183
- ax.grid(True, **{k: v for k, v in grid_params.items() if k != 'visible'})
274
+ grid_cfg = default_params['grid']
275
+ if grid_cfg.get('visible', True):
276
+ ax.grid(True, which=grid_cfg.get('which', 'major'),
277
+ color=grid_cfg.get('color', 'gray'), alpha=grid_cfg.get('alpha', 0.3))
184
278
  else:
185
- ax.grid(False, which='both')
279
+ ax.grid(False)
280
+
281
+ if index_type == 'string':
282
+ ticks = ax.get_xticks()
283
+ labels = [t.get_text() for t in ax.get_xticklabels()]
284
+ n = 10
285
+ ax.set_xticks(ticks[::n])
286
+ ax.set_xticklabels(labels[::n], rotation=90, ha='center')
287
+
186
288
 
187
289
  ax.set_title(default_params['title'], fontsize=20)
188
- ax.set_xlabel(default_params['xlabel'])
189
- ax.set_ylabel(default_params['ylabel'])
290
+
291
+ if index_type == 'date':
292
+ ax.set_xlabel(default_params.get('xlabel', 'Date'))
293
+ elif index_type == 'string':
294
+ ax.set_xlabel(default_params.get('xlabel', 'Label'))
295
+ else:
296
+ ax.set_xlabel(default_params.get('xlabel', 'Index'))
297
+
298
+ ax.set_ylabel(default_params.get('ylabel', 'Value'))
190
299
 
191
300
  # Create custom legend handles (colored boxes)
192
301
  legend_handles = [
@@ -0,0 +1,216 @@
1
+ """**Index Preparation for the Detection Pipeline**
2
+
3
+ PyTrendy's pipeline operates on a positional integer index internally, regardless of
4
+ the index the user supplies. This decouples the detection logic from any particular
5
+ time axis (daily, weekly, or otherwise) and lets the same pipeline accept datetime,
6
+ integer, float, or string indexes.
7
+
8
+ The preparation is a two-way translation:
9
+
10
+ 1. **Inbound** — the user's index column (``date_col``) is inspected to determine its
11
+ type, its values are captured, and the working DataFrame is re-staged on an internal
12
+ integer index (``0..n-1``). A lookup table maps internal positions back to the
13
+ original external index values.
14
+
15
+ 2. **Outbound** — once segments are detected on the internal index, their boundaries are
16
+ remapped back to the external index values before the results are returned or plotted.
17
+
18
+ The functions in this module encapsulate that translation so ``detect_trends`` stays
19
+ focused on orchestrating the pipeline.
20
+ """
21
+
22
+ import warnings
23
+ from copy import deepcopy
24
+
25
+ import numpy as np
26
+ import pandas as pd
27
+
28
+
29
+ def detect_index_type(values) -> str:
30
+ """
31
+ Detect the index type from a Series or Index of values.
32
+
33
+ Args:
34
+ values: A pandas Series or Index of index values.
35
+
36
+ Returns:
37
+ str: Index type (``'date'``, ``'datetime64'``, ``'integer'``, ``'float'``, or ``'string'``).
38
+ """
39
+ if pd.api.types.is_string_dtype(values):
40
+ with warnings.catch_warnings():
41
+ warnings.filterwarnings(
42
+ "ignore",
43
+ message="Could not infer format.*"
44
+ )
45
+ parsed = pd.to_datetime(values, errors="coerce")
46
+
47
+ if parsed.notna().all():
48
+ return "date"
49
+ else:
50
+ return "string"
51
+ elif pd.api.types.is_datetime64_any_dtype(values):
52
+ return "datetime64"
53
+ elif pd.api.types.is_integer_dtype(values):
54
+ return "integer"
55
+ elif pd.api.types.is_float_dtype(values):
56
+ return "float"
57
+ else:
58
+ raise NotImplementedError(f"unimplemented dtype {values.dtype}")
59
+
60
+
61
+ def is_legacy_positional_order(df: pd.DataFrame, value_col: str, date_col: str) -> bool:
62
+ """
63
+ Detect the deprecated ``detect_trends(df, date_col, value_col)`` positional order.
64
+
65
+ Under the old API the first positional column was always date-like (it was passed
66
+ through ``pd.to_datetime``) and the second was the numeric value column. So if the
67
+ column currently bound as ``value_col`` is date-like while ``date_col`` is numeric,
68
+ the caller almost certainly used the legacy order.
69
+
70
+ Args:
71
+ df (pd.DataFrame): Input DataFrame.
72
+ value_col (str): Column currently bound as the value/signal column.
73
+ date_col (str): Column currently bound as the date/index column.
74
+
75
+ Returns:
76
+ bool: True if the arguments appear to be in the legacy (date_col, value_col) order.
77
+ """
78
+ value_dtype = df[value_col].dtype
79
+ date_dtype = df[date_col].dtype
80
+
81
+ value_is_datelike = pd.api.types.is_datetime64_any_dtype(value_dtype) or (
82
+ pd.api.types.is_string_dtype(value_dtype)
83
+ and pd.to_datetime(df[value_col], errors="coerce").notna().all()
84
+ )
85
+ date_is_numeric = pd.api.types.is_numeric_dtype(date_dtype)
86
+
87
+ return value_is_datelike and date_is_numeric
88
+
89
+
90
+ def build_index_lookup(external_index) -> dict:
91
+ """
92
+ Build a lookup mapping internal integer positions to external index values.
93
+
94
+ Args:
95
+ external_index: The original index values captured before staging.
96
+
97
+ Returns:
98
+ dict: Mapping from internal position (``int``) to external index value.
99
+ """
100
+ internal_index = np.arange(len(external_index))
101
+ return dict(zip(internal_index, np.asarray(external_index)))
102
+
103
+
104
+ def prepare_index(df: pd.DataFrame, date_col: str | None, value_col: str) -> tuple:
105
+ """
106
+ Prepare the internal index framework used by the pipeline.
107
+
108
+ Detects the index type, captures the external index values, builds the
109
+ internal integer index and its lookup, and stages the working DataFrame
110
+ on a dedicated scratch column so the user's columns are never clobbered.
111
+
112
+ Args:
113
+ df (pd.DataFrame): Input time series DataFrame.
114
+ date_col (str|None): Name of the column representing the external index.
115
+ value_col (str): Name of the signal column.
116
+
117
+ Returns:
118
+ tuple: ``(df, external_index, index_lookup, index_type)`` where ``df`` is
119
+ the internal-indexed working copy, ``external_index`` holds the original
120
+ index values, ``index_lookup`` maps internal to external index values, and
121
+ ``index_type`` is the detected index type.
122
+ """
123
+ df = df.copy()
124
+
125
+ if date_col is not None:
126
+ index_type = detect_index_type(df[date_col])
127
+ index_values = df[date_col]
128
+
129
+ # Sort sortable index types ascending so the internal positional index
130
+ # reflects chronological (date) / numeric order. Non-sortable types
131
+ # ('string') keep their given order.
132
+ if index_type in ('date', 'datetime64', 'integer', 'float'):
133
+ sort_key = pd.to_datetime(index_values) if index_type == 'date' else index_values
134
+ df = df.iloc[np.asarray(sort_key).argsort(kind='stable')].reset_index(drop=True)
135
+ external_index = df[date_col].copy()
136
+
137
+ if index_type == 'string':
138
+ warnings.warn(
139
+ f"Attempting to cast {date_col} to date failed, "
140
+ "treating as string lookup.",
141
+ UserWarning,
142
+ stacklevel=2,
143
+ )
144
+ else:
145
+ # No date column: fall back to the DataFrame's own index.
146
+ index_type = detect_index_type(df.index)
147
+ index_values = df.index
148
+
149
+ # Sort sortable index types ascending; non-sortable ('string') and the
150
+ # default integer index keep their given order.
151
+ if index_type in ('datetime64', 'integer', 'float'):
152
+ df = df.sort_index(kind='stable')
153
+ elif index_type == 'date': # string-date index: sort chronologically, keep labels
154
+ order = np.asarray(pd.to_datetime(df.index)).argsort(kind='stable')
155
+ df = df.iloc[order]
156
+ external_index = np.asarray(df.index)
157
+
158
+ if index_type == 'float' and pd.isna(index_values).any():
159
+ warnings.warn(
160
+ "float index contains NaN values; they sort to the end and may "
161
+ "produce unexpected segment boundaries.",
162
+ UserWarning,
163
+ stacklevel=2,
164
+ )
165
+
166
+ index_lookup = build_index_lookup(external_index)
167
+
168
+ # Use a dedicated scratch column name to avoid clobbering user's columns
169
+ _pytrendy_idx = '_pytrendy_idx'
170
+ df[_pytrendy_idx] = np.arange(len(df))
171
+ df.set_index(_pytrendy_idx, inplace=True)
172
+ df = df[[value_col]]
173
+
174
+ return df, external_index, index_lookup, index_type
175
+
176
+
177
+ def remap_boundaries(segments: list[dict], index_lookup: dict) -> list[dict]:
178
+ """
179
+ Remap internal segment boundaries back to external index values.
180
+
181
+ Args:
182
+ segments (list): Segment list with internal index boundaries.
183
+ index_lookup (dict): Mapping from internal to external index values.
184
+
185
+ Returns:
186
+ list: A new segment list with boundaries expressed in external index values.
187
+ """
188
+ remapped = deepcopy(segments)
189
+ for segment in remapped:
190
+ segment['start'] = index_lookup[segment['start']]
191
+ segment['end'] = index_lookup[segment['end']]
192
+ return remapped
193
+
194
+
195
+ def prepare_plot_frame(df: pd.DataFrame, date_col: str | None, external_index, index_type: str) -> pd.DataFrame:
196
+ """
197
+ Restore the external index onto the working DataFrame for plotting.
198
+
199
+ Args:
200
+ df (pd.DataFrame): Internal-indexed working DataFrame.
201
+ date_col (str|None): Name of the external index column.
202
+ external_index: External index values captured before staging.
203
+ index_type (str): Detected index type.
204
+
205
+ Returns:
206
+ pd.DataFrame: DataFrame with the external index restored for plotting.
207
+ """
208
+ if index_type == 'date':
209
+ external_index = pd.to_datetime(external_index)
210
+
211
+ # Use a sentinel name when no date column was supplied, so the restored
212
+ # index has a meaningful label rather than a None-named column.
213
+ index_name = date_col if date_col is not None else '_index'
214
+ df[index_name] = np.asarray(external_index)
215
+ df.set_index(index_name, inplace=True)
216
+ return df
@@ -14,17 +14,23 @@ class PyTrendyResults:
14
14
  enhanced metrics such as rankings and signal-to-noise ratios.
15
15
  """
16
16
 
17
- def __init__(self, segments: list[dict]) -> None:
17
+ def __init__(self, segments: list[dict], index_type: str = 'date') -> None:
18
18
  """
19
19
  Initializes the results object with a list of segments.
20
20
 
21
21
  Args:
22
22
  segments (list):
23
23
  List of dictionaries representing individual trend segments.
24
+ index_type (str):
25
+ The type of the index used for the segments (``'date'``, ``'datetime64'``,
26
+ ``'integer'``, ``'float'``, or ``'string'``). Used to render summaries with the
27
+ appropriate descriptor (e.g. ``'days'`` vs ``'index steps'``). Defaults to ``'date'``.
24
28
  """
25
29
  self.segments = segments
26
30
  self.trend_segments = [seg for seg in self.segments if 'trend_class' in seg] # Get segments that are trends (exclude flats and noise)
27
31
 
32
+ self.index_type = index_type
33
+
28
34
  self.set_best()
29
35
  self.set_df()
30
36
  self.set_summary()
@@ -70,7 +76,17 @@ class PyTrendyResults:
70
76
 
71
77
  # Set summary df (without extra details)
72
78
  df = pd.DataFrame(self.segments)
73
- cols = ['time_index', 'direction', 'start', 'end', 'days', 'total_change', 'change_rank']
79
+
80
+ unit_descriptor = {
81
+ 'date': 'days',
82
+ 'integer': 'index steps',
83
+ 'float': 'index steps',
84
+ 'string': 'index steps',
85
+ }.get(self.index_type, 'days')
86
+
87
+ df = df.rename({'days' : unit_descriptor}, axis = 1)
88
+
89
+ cols = ['time_index', 'direction', 'start', 'end', unit_descriptor, 'total_change', 'change_rank']
74
90
  if len(changes) > 1: # only include trend_class if atleast one trend exists
75
91
  cols += ['trend_class']
76
92
  df = df[cols]
@@ -94,11 +110,17 @@ class PyTrendyResults:
94
110
  noise = self.summary['direction_counts']['Noise'] if 'Noise' in self.summary['direction_counts'] else 0
95
111
  print(f'Detected: \n- {uptrends} Uptrends. \n- {downtrends} Downtrends.\n- {flats} Flats.\n- {noise} Noise.\n')
96
112
 
113
+ descriptor = 'dates'
114
+ if self.index_type in ['integer', 'float']:
115
+ descriptor = 'indexes'
116
+ elif self.index_type in ['string']:
117
+ descriptor = 'labels'
118
+
97
119
  if len(self.filter_segments(direction='Up/Down')) == 0:
98
120
  print('Detected no trends...')
99
121
  return
100
122
  else:
101
- print(f'The best detected trend is {self.best["direction"]} between dates {self.best["start"]} - {self.best["end"]}\n')
123
+ print(f'The best detected trend is {self.best["direction"]} between {descriptor} {self.best["start"]} - {self.best["end"]}\n')
102
124
 
103
125
  print('Full Results:')
104
126
  print('-------------------------------------------------------------------------------\n',
@@ -53,7 +53,7 @@ def analyse_segments(df: pd.DataFrame, value_col: str, segments: list[dict]) ->
53
53
  segment_enhanced['pct_change'] = (float(val_end / val_start - 1) if val_start != 0 else np.nan)
54
54
 
55
55
  # Calculate days & cumulative total change
56
- days = (pd.to_datetime(segment['end']) - pd.to_datetime(segment['start'])).days
56
+ days = segment['end'] - segment['start']
57
57
  if days == 0:
58
58
  days = 1 # edge case for 1 day flat between noise spike & trend
59
59
  segment_enhanced['days'] = days # set days