priorityx 0.1.1__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.
@@ -0,0 +1,483 @@
1
+ """Cumulative movement tracking through priority quadrants."""
2
+
3
+ from datetime import timedelta
4
+ from typing import Optional, Sequence, Union
5
+
6
+ import pandas as pd
7
+ import polars as pl
8
+
9
+
10
+ def _is_quarter_start(ts: pd.Timestamp) -> bool:
11
+ """Check if timestamp is the first day of a calendar quarter."""
12
+ return ts.day == 1 and ts.month in (1, 4, 7, 10)
13
+
14
+
15
+ def _next_quarter_start(ts: pd.Timestamp) -> pd.Timestamp:
16
+ """Get timestamp for the first day of the next calendar quarter."""
17
+ if ts.month in (1, 4, 7, 10):
18
+ month = ts.month
19
+ else:
20
+ # fallback to nearest lower quarter
21
+ month = ((ts.month - 1) // 3) * 3 + 1
22
+ ts = pd.Timestamp(year=ts.year, month=month, day=1)
23
+
24
+ if month == 1:
25
+ next_month = 4
26
+ next_year = ts.year
27
+ elif month == 4:
28
+ next_month = 7
29
+ next_year = ts.year
30
+ elif month == 7:
31
+ next_month = 10
32
+ next_year = ts.year
33
+ else: # month == 10
34
+ next_month = 1
35
+ next_year = ts.year + 1
36
+
37
+ return pd.Timestamp(year=next_year, month=next_month, day=1)
38
+
39
+
40
+ def _quarter_label(ts: pd.Timestamp) -> str:
41
+ """Format timestamp into 'YYYY-QX' label."""
42
+ quarter = ((ts.month - 1) // 3) + 1
43
+ return f"{ts.year}-Q{quarter}"
44
+
45
+
46
+ def _build_quarter_schedule_from_range(
47
+ start_date: str, end_date: str
48
+ ) -> list[tuple[str, str]]:
49
+ """
50
+ Build quarter schedule from date range.
51
+
52
+ Args:
53
+ start_date: Start date (YYYY-MM-DD), must be quarter boundary
54
+ end_date: End date (YYYY-MM-DD), must be quarter boundary
55
+
56
+ Returns:
57
+ List of (label, exclusive_end_date) tuples
58
+
59
+ Raises:
60
+ ValueError: If dates are not quarter boundaries or invalid
61
+ """
62
+ start_ts = pd.Timestamp(start_date)
63
+ end_ts = pd.Timestamp(end_date)
64
+
65
+ if start_ts >= end_ts:
66
+ raise ValueError("Start date must be earlier than end date")
67
+
68
+ if not _is_quarter_start(start_ts) or not _is_quarter_start(end_ts):
69
+ raise ValueError(
70
+ "Dates must be quarter boundaries (YYYY-01-01, YYYY-04-01, "
71
+ "YYYY-07-01, or YYYY-10-01)"
72
+ )
73
+
74
+ schedule = []
75
+ current = start_ts
76
+ while current < end_ts:
77
+ next_start = _next_quarter_start(current)
78
+ if next_start > end_ts:
79
+ next_start = end_ts
80
+
81
+ schedule.append((_quarter_label(current), next_start.strftime("%Y-%m-%d")))
82
+ current = next_start
83
+
84
+ return schedule
85
+
86
+
87
+ def _build_default_quarter_schedule(
88
+ df: pl.DataFrame, timestamp_col: str
89
+ ) -> list[tuple[str, str]]:
90
+ """
91
+ Create default quarter schedule spanning the dataset.
92
+
93
+ Args:
94
+ df: Input polars DataFrame
95
+ timestamp_col: Column name for timestamp
96
+
97
+ Returns:
98
+ List of (label, exclusive_end_date) tuples
99
+ """
100
+ if timestamp_col not in df.columns or df.height == 0:
101
+ return []
102
+
103
+ min_date = df[timestamp_col].min()
104
+ max_date = df[timestamp_col].max()
105
+
106
+ if min_date is None or max_date is None:
107
+ return []
108
+
109
+ start_ts = pd.Timestamp(min_date).to_period("Q").to_timestamp(how="start")
110
+ # exclusive boundary: first day of quarter following max_date
111
+ max_quarter_start = pd.Timestamp(max_date).to_period("Q").to_timestamp(how="start")
112
+ end_ts = _next_quarter_start(max_quarter_start)
113
+
114
+ return _build_quarter_schedule_from_range(
115
+ start_ts.strftime("%Y-%m-%d"), end_ts.strftime("%Y-%m-%d")
116
+ )
117
+
118
+
119
+ def normalize_quarter_schedule(
120
+ quarters: Optional[Sequence[Union[tuple[str, str], str]]],
121
+ df: pl.DataFrame,
122
+ timestamp_col: str,
123
+ ) -> list[tuple[str, str]]:
124
+ """
125
+ Normalize quarter schedule to standard format.
126
+
127
+ Accepts three input formats:
128
+ 1. Explicit schedule: [("2024-Q2", "2024-07-01"), ...]
129
+ 2. Date range: ["2024-01-01", "2025-10-01"] (auto-generates quarters)
130
+ 3. None: Auto-detect from data
131
+
132
+ Args:
133
+ quarters: Quarter specification (see formats above)
134
+ df: Input polars DataFrame
135
+ timestamp_col: Column name for timestamp
136
+
137
+ Returns:
138
+ List of (quarter_label, exclusive_end_date) tuples
139
+
140
+ Examples:
141
+ >>> # explicit schedule
142
+ >>> normalize_quarter_schedule(
143
+ ... [("2024-Q1", "2024-04-01"), ("2024-Q2", "2024-07-01")],
144
+ ... df, "date"
145
+ ... )
146
+
147
+ >>> # date range (auto-generates quarters)
148
+ >>> normalize_quarter_schedule(
149
+ ... ["2024-01-01", "2025-01-01"], df, "date"
150
+ ... )
151
+
152
+ >>> # auto-detect from data
153
+ >>> normalize_quarter_schedule(None, df, "date")
154
+ """
155
+ if quarters is None:
156
+ return _build_default_quarter_schedule(df, timestamp_col)
157
+
158
+ if len(quarters) == 0:
159
+ return []
160
+
161
+ # check first element type
162
+ quarters[0]
163
+
164
+ # date range format: ["2024-01-01", "2025-10-01"]
165
+ if len(quarters) == 2 and all(isinstance(q, str) for q in quarters):
166
+ return _build_quarter_schedule_from_range(quarters[0], quarters[1])
167
+
168
+ # explicit schedule format: [("2024-Q2", "2024-07-01"), ...]
169
+ normalized_schedule: list[tuple[str, str]] = []
170
+ for entry in quarters:
171
+ if not isinstance(entry, tuple) or len(entry) != 2:
172
+ raise ValueError(
173
+ "quarters must be a list of (label, cutoff_date) tuples "
174
+ "or two quarter boundary strings"
175
+ )
176
+ label, cutoff = entry
177
+ normalized_schedule.append((label, cutoff))
178
+
179
+ return normalized_schedule
180
+
181
+
182
+ def track_cumulative_movement(
183
+ df: pl.DataFrame | pl.LazyFrame,
184
+ entity_col: str,
185
+ timestamp_col: str,
186
+ quarters: Optional[Sequence[Union[tuple[str, str], str]]] = None,
187
+ min_total_count: int = 20,
188
+ decline_window_quarters: int = 6,
189
+ temporal_granularity: str = "quarterly",
190
+ vcp_p: float = 3.5,
191
+ fe_p: float = 3.0,
192
+ ) -> tuple[pd.DataFrame, dict]:
193
+ """
194
+ Track entity movement through priority quadrants over time.
195
+
196
+ Uses cumulative data periods to track X/Y movement while maintaining
197
+ stable global baseline classification. Three-step process:
198
+
199
+ 1. Global baseline: GLMM on full dataset → stable quadrant assignment
200
+ 2. Endpoint cohorting: Define valid entities based on endpoint criteria
201
+ 3. Quarter-by-quarter tracking: Track X/Y movement for valid entities
202
+
203
+ Args:
204
+ df: Input polars DataFrame or LazyFrame
205
+ entity_col: Column name for entity identifier
206
+ timestamp_col: Column name for timestamp (Date type)
207
+ quarters: Quarter specification (see normalize_quarter_schedule)
208
+ min_total_count: Minimum total count for inclusion (default: 20)
209
+ decline_window_quarters: Max quarters after last observation (default: 6)
210
+ temporal_granularity: Time granularity for GLMM ('quarterly', 'yearly', 'semiannual')
211
+ vcp_p: Prior scale for random effects (default: 3.5)
212
+ fe_p: Prior scale for fixed effects (default: 3.0)
213
+
214
+ Returns:
215
+ Tuple of (movement_df, metadata_dict)
216
+ - movement_df: Quarter-by-quarter tracking with columns:
217
+ [entity, quarter, count_to_date, period_x, period_y, period_quadrant,
218
+ global_quadrant, global_x, global_y, count_total, x_delta, y_delta, volume_delta]
219
+ - metadata_dict: Tracking statistics and configuration
220
+
221
+ Examples:
222
+ >>> # auto-detect quarters from data
223
+ >>> movement_df, meta = track_cumulative_movement(
224
+ ... df, entity_col="service", timestamp_col="date"
225
+ ... )
226
+
227
+ >>> # specify date range
228
+ >>> movement_df, meta = track_cumulative_movement(
229
+ ... df, entity_col="service", timestamp_col="date",
230
+ ... quarters=["2024-01-01", "2025-01-01"]
231
+ ... )
232
+ """
233
+ # ensure dataframe
234
+ if isinstance(df, pl.LazyFrame):
235
+ df = df.collect()
236
+
237
+ # normalize quarter schedule
238
+ quarter_schedule = normalize_quarter_schedule(quarters, df, timestamp_col)
239
+
240
+ if len(quarter_schedule) == 0:
241
+ print("Warning: No quarter boundaries available for cumulative tracking")
242
+ empty_df = pd.DataFrame(
243
+ columns=[
244
+ "entity",
245
+ "quarter",
246
+ "count_to_date",
247
+ "period_x",
248
+ "period_y",
249
+ "period_quadrant",
250
+ "global_quadrant",
251
+ "global_x",
252
+ "global_y",
253
+ "count_total",
254
+ ]
255
+ )
256
+ metadata = {
257
+ "entities_tracked": 0,
258
+ "quarters_analyzed": 0,
259
+ "total_observations": 0,
260
+ "divergence_rate": 0.0,
261
+ "temporal_granularity": temporal_granularity,
262
+ "quarter_schedule": [],
263
+ }
264
+ return empty_df, metadata
265
+
266
+ print("\nCUMULATIVE MOVEMENT TRACKING")
267
+
268
+ # import fit_priority_matrix
269
+ from ..core.glmm import fit_priority_matrix
270
+
271
+ # step 1: calculate global baseline from full dataset
272
+ print("\n[1/3] Calculating global baseline (FULL dataset)...")
273
+ global_results, _ = fit_priority_matrix(
274
+ df,
275
+ entity_col=entity_col,
276
+ timestamp_col=timestamp_col,
277
+ min_observations=0,
278
+ min_total_count=min_total_count,
279
+ decline_window_quarters=0, # don't filter for global baseline
280
+ temporal_granularity=temporal_granularity,
281
+ vcp_p=vcp_p,
282
+ fe_p=fe_p,
283
+ )
284
+
285
+ print(f"Global baseline: {len(global_results)} entities")
286
+
287
+ # create global baseline lookup
288
+ global_baseline = {}
289
+ for _, row in global_results.iterrows():
290
+ entity = row[entity_col]
291
+ global_baseline[entity] = {
292
+ "global_quadrant": row["quadrant"],
293
+ "global_x": row["Random_Intercept"],
294
+ "global_y": row["Random_Slope"],
295
+ "count_total": row["count"],
296
+ }
297
+
298
+ # step 2: determine valid entities at analysis endpoint
299
+ print("\n[2/3] Determining valid entities at analysis endpoint...")
300
+
301
+ # get endpoint (last quarter)
302
+ final_quarter_name, final_quarter_date = quarter_schedule[-1]
303
+ print(f"Analysis endpoint: {final_quarter_name} ({final_quarter_date})")
304
+
305
+ # calculate valid entities based on cumulative totals up to endpoint
306
+ endpoint_pl = pl.lit(final_quarter_date).str.to_date()
307
+ cumulative_up_to_endpoint = df.filter(pl.col(timestamp_col) < endpoint_pl)
308
+
309
+ # apply filters in same order as fit_priority_matrix for consistency
310
+
311
+ # step 1: filter by min_total_count
312
+ endpoint_totals = cumulative_up_to_endpoint.group_by(entity_col).agg(
313
+ pl.len().alias("total")
314
+ )
315
+ entities_above_threshold = endpoint_totals.filter(
316
+ pl.col("total") >= min_total_count
317
+ )[entity_col]
318
+ cumulative_up_to_endpoint = cumulative_up_to_endpoint.filter(
319
+ pl.col(entity_col).is_in(entities_above_threshold)
320
+ )
321
+
322
+ # step 2: apply decline_window filter
323
+ if decline_window_quarters > 0:
324
+ dataset_max_datetime = cumulative_up_to_endpoint.select(
325
+ pl.col(timestamp_col).max()
326
+ ).item()
327
+ dataset_max_date = (
328
+ dataset_max_datetime.date()
329
+ if hasattr(dataset_max_datetime, "date")
330
+ else dataset_max_datetime
331
+ )
332
+ decline_cutoff = dataset_max_date - timedelta(
333
+ days=decline_window_quarters * 91
334
+ )
335
+
336
+ last_observation_at_endpoint = cumulative_up_to_endpoint.group_by(
337
+ entity_col
338
+ ).agg(pl.col(timestamp_col).max().alias("last_date"))
339
+ stale_entities_at_endpoint = last_observation_at_endpoint.filter(
340
+ pl.col("last_date").cast(pl.Date) < pl.lit(decline_cutoff).cast(pl.Date)
341
+ )[entity_col]
342
+ cumulative_up_to_endpoint = cumulative_up_to_endpoint.filter(
343
+ ~pl.col(entity_col).is_in(stale_entities_at_endpoint)
344
+ )
345
+ n_stale = len(stale_entities_at_endpoint)
346
+ if n_stale > 0:
347
+ print(
348
+ f" Filtered {n_stale} stale entities at endpoint "
349
+ f"(inactive >{decline_window_quarters}Q)"
350
+ )
351
+
352
+ # get final entity list after filters
353
+ valid_entity_list = (
354
+ cumulative_up_to_endpoint.select(entity_col).unique().to_series().to_list()
355
+ )
356
+
357
+ n_valid_at_endpoint = len(valid_entity_list)
358
+ print(
359
+ f" Valid entities (≥{min_total_count} count by {final_quarter_name}): "
360
+ f"{n_valid_at_endpoint}"
361
+ )
362
+
363
+ # step 3: track movement through quarters
364
+ print(f"\n[3/3] Tracking movement through {len(quarter_schedule)} quarters...")
365
+
366
+ movement_records = []
367
+
368
+ for quarter_name, end_date_str in quarter_schedule:
369
+ print(f" [{quarter_name}] Running GLMM on cumulative data...", end=" ")
370
+
371
+ # filter to cumulative data up to this quarter
372
+ end_pl = pl.lit(end_date_str).str.to_date()
373
+ cumulative_df = df.filter(
374
+ (pl.col(timestamp_col) < end_pl)
375
+ & (pl.col(entity_col).is_in(valid_entity_list))
376
+ )
377
+
378
+ if len(cumulative_df) < 100:
379
+ print(f"⚠ Insufficient data ({len(cumulative_df)} rows)")
380
+ continue
381
+
382
+ # run glmm on this cumulative period
383
+ try:
384
+ period_results, _ = fit_priority_matrix(
385
+ cumulative_df,
386
+ entity_col=entity_col,
387
+ timestamp_col=timestamp_col,
388
+ min_observations=0,
389
+ min_total_count=0,
390
+ decline_window_quarters=0,
391
+ temporal_granularity=temporal_granularity,
392
+ vcp_p=vcp_p,
393
+ fe_p=fe_p,
394
+ )
395
+
396
+ print(f"{len(period_results)} entities")
397
+
398
+ # merge with global baseline
399
+ for _, row in period_results.iterrows():
400
+ entity = row[entity_col]
401
+
402
+ if entity in global_baseline:
403
+ movement_records.append(
404
+ {
405
+ "entity": entity,
406
+ "quarter": quarter_name,
407
+ "count_to_date": row["count"],
408
+ "period_x": row["Random_Intercept"],
409
+ "period_y": row["Random_Slope"],
410
+ "period_quadrant": row["quadrant"],
411
+ "global_quadrant": global_baseline[entity][
412
+ "global_quadrant"
413
+ ],
414
+ "global_x": global_baseline[entity]["global_x"],
415
+ "global_y": global_baseline[entity]["global_y"],
416
+ "count_total": global_baseline[entity]["count_total"],
417
+ }
418
+ )
419
+
420
+ except Exception as e:
421
+ print(f"✗ Error: {str(e)[:50]}")
422
+ continue
423
+
424
+ movement_df = pd.DataFrame(movement_records)
425
+
426
+ if movement_df.empty:
427
+ movement_df = pd.DataFrame(
428
+ columns=[
429
+ "entity",
430
+ "quarter",
431
+ "count_to_date",
432
+ "period_x",
433
+ "period_y",
434
+ "period_quadrant",
435
+ "global_quadrant",
436
+ "global_x",
437
+ "global_y",
438
+ "count_total",
439
+ "x_delta",
440
+ "y_delta",
441
+ "volume_delta",
442
+ ]
443
+ )
444
+
445
+ # step 4: calculate movement metrics
446
+ print("\n[4/4] Calculating movement metrics...")
447
+
448
+ # add quarter-over-quarter changes
449
+ movement_df = movement_df.sort_values(["entity", "quarter"])
450
+
451
+ # calculate deltas
452
+ movement_df["x_delta"] = movement_df.groupby("entity")["period_x"].diff()
453
+ movement_df["y_delta"] = movement_df.groupby("entity")["period_y"].diff()
454
+ movement_df["volume_delta"] = movement_df.groupby("entity")[
455
+ "count_to_date"
456
+ ].diff()
457
+
458
+ # detect quadrant divergence
459
+ movement_df["quadrant_differs"] = (
460
+ movement_df["period_quadrant"] != movement_df["global_quadrant"]
461
+ )
462
+
463
+ print(f"Tracked {len(movement_df)} entity-quarter observations")
464
+ print(f"Entities tracked: {movement_df['entity'].nunique()}")
465
+ print(f"Periods covered: {movement_df['quarter'].nunique()}")
466
+
467
+ divergence_count = movement_df["quadrant_differs"].sum()
468
+ divergence_pct = (
469
+ (divergence_count / len(movement_df) * 100) if len(movement_df) > 0 else 0
470
+ )
471
+ print(f"Quadrant divergences: {divergence_count} ({divergence_pct:.1f}%)")
472
+
473
+ # metadata
474
+ metadata = {
475
+ "entities_tracked": movement_df["entity"].nunique() if not movement_df.empty else 0,
476
+ "quarters_analyzed": len(quarter_schedule),
477
+ "total_observations": len(movement_df),
478
+ "divergence_rate": divergence_pct,
479
+ "temporal_granularity": temporal_granularity,
480
+ "quarter_schedule": quarter_schedule,
481
+ }
482
+
483
+ return movement_df, metadata
@@ -0,0 +1,214 @@
1
+ """Transition detection from movement tracking data."""
2
+
3
+ from typing import Tuple
4
+
5
+ import pandas as pd
6
+
7
+
8
+ def classify_transition_risk(from_quadrant: str, to_quadrant: str) -> Tuple[str, str]:
9
+ """
10
+ Classify risk level and type of a quadrant transition.
11
+
12
+ Maps quadrant-to-quadrant transitions to risk levels based on
13
+ trajectory through priority space.
14
+
15
+ Args:
16
+ from_quadrant: Starting quadrant (Q1-Q4)
17
+ to_quadrant: Ending quadrant (Q1-Q4)
18
+
19
+ Returns:
20
+ Tuple of (risk_level, transition_description)
21
+ - risk_level: "critical", "high", "medium", "low", or "stable"
22
+ - transition_description: Human-readable description
23
+
24
+ Risk Classification:
25
+ **Critical**: Transitions TO Q1 (Crisis)
26
+ - Q2→Q1: Emerging issue became critical
27
+ - Q3→Q1: Low priority escalated to critical
28
+ - Q4→Q1: Persistent issue became critical
29
+
30
+ **High**: Transitions TO Q2 (Investigate)
31
+ - Q3→Q2: Low priority becoming emerging threat
32
+ - Q4→Q2: Persistent issue showing growth
33
+
34
+ **Medium**: Other risk-increasing transitions
35
+ - Q4→Q3: Persistent issue losing volume (watch)
36
+
37
+ **Low**: Risk-decreasing transitions
38
+ - Q1→Q2: Critical issue volume decreasing
39
+ - Q1→Q4: Critical issue growth slowing
40
+ - Q2→Q3: Emerging threat growth slowing
41
+
42
+ **Stable**: All other transitions
43
+
44
+ Examples:
45
+ >>> classify_transition_risk("Q2", "Q1")
46
+ ('critical', 'Emerging issue became critical')
47
+
48
+ >>> classify_transition_risk("Q3", "Q2")
49
+ ('high', 'Low priority becoming emerging threat')
50
+
51
+ >>> classify_transition_risk("Q1", "Q4")
52
+ ('low', 'Critical issue growth slowing')
53
+ """
54
+ # critical transitions (moving to Q1)
55
+ critical_transitions = {
56
+ ("Q2", "Q1"): "Emerging issue became critical",
57
+ ("Q3", "Q1"): "Low priority escalated to critical",
58
+ ("Q4", "Q1"): "Persistent issue became critical",
59
+ }
60
+
61
+ # high risk transitions (moving to Q2)
62
+ high_risk_transitions = {
63
+ ("Q3", "Q2"): "Low priority becoming emerging threat",
64
+ ("Q4", "Q2"): "Persistent issue showing growth",
65
+ }
66
+
67
+ # medium risk transitions
68
+ medium_risk_transitions = {
69
+ ("Q4", "Q3"): "Persistent issue losing volume (watch)"
70
+ }
71
+
72
+ # low risk transitions (improvements)
73
+ low_risk_transitions = {
74
+ ("Q1", "Q2"): "Critical issue volume decreasing",
75
+ ("Q1", "Q4"): "Critical issue growth slowing",
76
+ ("Q2", "Q3"): "Emerging threat growth slowing",
77
+ }
78
+
79
+ transition_key = (from_quadrant, to_quadrant)
80
+
81
+ if transition_key in critical_transitions:
82
+ return "critical", critical_transitions[transition_key]
83
+ elif transition_key in high_risk_transitions:
84
+ return "high", high_risk_transitions[transition_key]
85
+ elif transition_key in medium_risk_transitions:
86
+ return "medium", medium_risk_transitions[transition_key]
87
+ elif transition_key in low_risk_transitions:
88
+ return "low", low_risk_transitions[transition_key]
89
+ else:
90
+ return "stable", f"Transition from {from_quadrant} to {to_quadrant}"
91
+
92
+
93
+ def extract_transitions(
94
+ movement_df: pd.DataFrame,
95
+ focus_risk_increasing: bool = True,
96
+ ) -> pd.DataFrame:
97
+ """
98
+ Extract quadrant transitions from cumulative movement tracking data.
99
+
100
+ Detects two types of transitions:
101
+ 1. Quadrant-to-quadrant transitions (Q3→Q2, Q2→Q1, etc.)
102
+ 2. Within-quadrant dramatic changes (velocity thresholds)
103
+
104
+ Key Innovation:
105
+ - Detects transitions from period_quadrant changes (actual movement)
106
+ - Classifies risk using global_quadrant (stable baseline)
107
+ - No oscillations in risk classification
108
+
109
+ Args:
110
+ movement_df: DataFrame from track_cumulative_movement()
111
+ Required columns: entity, quarter, period_quadrant,
112
+ global_quadrant, period_x, period_y, x_delta, y_delta
113
+ focus_risk_increasing: Only return risk-increasing transitions (default: True)
114
+ If False, includes all transitions including improvements
115
+
116
+ Returns:
117
+ DataFrame with columns:
118
+ - entity: Entity name
119
+ - transition_quarter: When transition occurred
120
+ - from_quadrant: Previous period quadrant
121
+ - to_quadrant: New period quadrant (with * for within-quadrant)
122
+ - risk_level: "critical", "high", "medium", "low", or "stable"
123
+ - volume_change: X-axis delta
124
+ - growth_change: Y-axis delta
125
+ - transition_type: Human-readable description
126
+ - global_quadrant: Stable baseline quadrant (for reference)
127
+
128
+ Examples:
129
+ >>> transitions = extract_transitions(movement_df)
130
+ >>> critical = transitions[transitions['risk_level'] == 'critical']
131
+
132
+ >>> # include all transitions (not just risk-increasing)
133
+ >>> all_trans = extract_transitions(movement_df, focus_risk_increasing=False)
134
+ """
135
+ transitions = []
136
+
137
+ # group by entity and sort by quarter
138
+ for entity, entity_data in movement_df.groupby("entity"):
139
+ entity_data = entity_data.sort_values("quarter")
140
+
141
+ # get global quadrant (stable across all periods)
142
+ global_quad = entity_data.iloc[0]["global_quadrant"]
143
+
144
+ # detect period quadrant transitions
145
+ for i in range(len(entity_data) - 1):
146
+ curr_row = entity_data.iloc[i]
147
+ next_row = entity_data.iloc[i + 1]
148
+
149
+ # check if period quadrant changed
150
+ if curr_row["period_quadrant"] != next_row["period_quadrant"]:
151
+ from_quad = curr_row["period_quadrant"]
152
+ to_quad = next_row["period_quadrant"]
153
+
154
+ # classify risk based on transition pattern
155
+ risk_level, transition_type = classify_transition_risk(
156
+ from_quad, to_quad
157
+ )
158
+
159
+ # filter if focusing on risk-increasing only
160
+ if focus_risk_increasing and risk_level in ["low", "stable"]:
161
+ continue
162
+
163
+ transitions.append(
164
+ {
165
+ "entity": entity,
166
+ "transition_quarter": next_row["quarter"],
167
+ "from_quadrant": from_quad,
168
+ "to_quadrant": to_quad,
169
+ "risk_level": risk_level,
170
+ "volume_change": next_row["x_delta"],
171
+ "growth_change": next_row["y_delta"],
172
+ "transition_type": transition_type,
173
+ "global_quadrant": global_quad,
174
+ }
175
+ )
176
+
177
+ # detect dramatic changes WITHIN same period quadrant
178
+ elif curr_row["period_quadrant"] == next_row["period_quadrant"]:
179
+ growth_change = next_row["y_delta"]
180
+ volume_change = next_row["x_delta"]
181
+
182
+ # flag dramatic acceleration (Y-axis surge > 1.0)
183
+ if growth_change > 1.0:
184
+ transitions.append(
185
+ {
186
+ "entity": entity,
187
+ "transition_quarter": next_row["quarter"],
188
+ "from_quadrant": curr_row["period_quadrant"],
189
+ "to_quadrant": f"{next_row['period_quadrant']}*", # * = within-quadrant
190
+ "risk_level": "critical",
191
+ "volume_change": volume_change,
192
+ "growth_change": growth_change,
193
+ "transition_type": f"Dramatic acceleration within {next_row['period_quadrant']} (+{growth_change:.1f} growth)",
194
+ "global_quadrant": global_quad,
195
+ }
196
+ )
197
+
198
+ # flag dramatic volume increase (X-axis surge > 1.0)
199
+ elif volume_change > 1.0:
200
+ transitions.append(
201
+ {
202
+ "entity": entity,
203
+ "transition_quarter": next_row["quarter"],
204
+ "from_quadrant": curr_row["period_quadrant"],
205
+ "to_quadrant": f"{next_row['period_quadrant']}*",
206
+ "risk_level": "high",
207
+ "volume_change": volume_change,
208
+ "growth_change": growth_change,
209
+ "transition_type": f"Major volume surge within {next_row['period_quadrant']} (+{volume_change:.1f} volume)",
210
+ "global_quadrant": global_quad,
211
+ }
212
+ )
213
+
214
+ return pd.DataFrame(transitions)
File without changes