priorityx 0.2.3__tar.gz → 0.2.5__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 (37) hide show
  1. {priorityx-0.2.3 → priorityx-0.2.5}/PKG-INFO +1 -1
  2. {priorityx-0.2.3 → priorityx-0.2.5}/pyproject.toml +1 -1
  3. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/__init__.py +1 -1
  4. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/tracking/drivers.py +65 -85
  5. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/tracking/movement.py +89 -7
  6. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/utils/helpers.py +19 -0
  7. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/viz/timeline.py +29 -26
  8. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/viz/trajectory.py +34 -8
  9. {priorityx-0.2.3 → priorityx-0.2.5}/tests/test_drivers.py +3 -3
  10. {priorityx-0.2.3 → priorityx-0.2.5}/uv.lock +1 -1
  11. {priorityx-0.2.3 → priorityx-0.2.5}/.github/workflows/release.yml +0 -0
  12. {priorityx-0.2.3 → priorityx-0.2.5}/.github/workflows/test.yml +0 -0
  13. {priorityx-0.2.3 → priorityx-0.2.5}/.gitignore +0 -0
  14. {priorityx-0.2.3 → priorityx-0.2.5}/LICENSE +0 -0
  15. {priorityx-0.2.3 → priorityx-0.2.5}/Makefile +0 -0
  16. {priorityx-0.2.3 → priorityx-0.2.5}/README.md +0 -0
  17. {priorityx-0.2.3 → priorityx-0.2.5}/docs/api-reference.md +0 -0
  18. {priorityx-0.2.3 → priorityx-0.2.5}/docs/methodology.md +0 -0
  19. {priorityx-0.2.3 → priorityx-0.2.5}/docs/priority_classification.md +0 -0
  20. {priorityx-0.2.3 → priorityx-0.2.5}/docs/quickstart.md +0 -0
  21. {priorityx-0.2.3 → priorityx-0.2.5}/examples/README.md +0 -0
  22. {priorityx-0.2.3 → priorityx-0.2.5}/examples/bugs/bugs_monitoring.py +0 -0
  23. {priorityx-0.2.3 → priorityx-0.2.5}/examples/incidents/incident_monitoring.py +0 -0
  24. {priorityx-0.2.3 → priorityx-0.2.5}/examples/violations/violations_monitoring.py +0 -0
  25. {priorityx-0.2.3 → priorityx-0.2.5}/scripts/version.py +0 -0
  26. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/core/__init__.py +0 -0
  27. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/core/filters.py +0 -0
  28. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/core/glmm.py +0 -0
  29. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/core/quadrants.py +0 -0
  30. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/tracking/__init__.py +0 -0
  31. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/tracking/transitions.py +0 -0
  32. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/utils/__init__.py +0 -0
  33. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/viz/__init__.py +0 -0
  34. {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/viz/matrix.py +0 -0
  35. {priorityx-0.2.3 → priorityx-0.2.5}/tests/test_glmm.py +0 -0
  36. {priorityx-0.2.3 → priorityx-0.2.5}/tests/test_quadrants.py +0 -0
  37. {priorityx-0.2.3 → priorityx-0.2.5}/tests/test_transitions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: priorityx
3
- Version: 0.2.3
3
+ Version: 0.2.5
4
4
  Summary: Entity prioritization and escalation detection using GLMM statistical models
5
5
  Project-URL: Homepage, https://github.com/okkymabruri/priorityx
6
6
  Project-URL: Repository, https://github.com/okkymabruri/priorityx
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "priorityx"
3
- version = "0.2.3"
3
+ version = "0.2.5"
4
4
  description = "Entity prioritization and escalation detection using GLMM statistical models"
5
5
  authors = [
6
6
  {name = "Okky Mabruri", email = "okkymbrur@gmail.com"}
@@ -1,6 +1,6 @@
1
1
  """priorityx: Entity prioritization and escalation detection using GLMM statistical models."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.2.5"
4
4
 
5
5
  # API will be imported
6
6
 
@@ -82,7 +82,9 @@ def _detect_subcategory_columns(
82
82
  continue
83
83
 
84
84
  series = df[col]
85
- if pd.api.types.is_object_dtype(series) or pd.api.types.is_categorical_dtype(series):
85
+ if pd.api.types.is_object_dtype(
86
+ series
87
+ ) or pd.api.types.is_categorical_dtype(series):
86
88
  unique_vals = series.nunique(dropna=True)
87
89
  if 1 < unique_vals <= max_unique:
88
90
  detected.append(col)
@@ -262,8 +264,8 @@ def extract_transition_drivers(
262
264
  entity_col: str = "entity",
263
265
  timestamp_col: str = "date",
264
266
  subcategory_cols: Optional[List[str]] = None,
265
- top_n_subcategories: int = 3,
266
- min_subcategory_delta: int = 1,
267
+ top_n_subcategories: int = 5,
268
+ min_subcategory_delta: int = 2,
267
269
  ) -> Dict:
268
270
  """
269
271
  Extract key drivers of a quadrant transition.
@@ -364,10 +366,8 @@ def extract_transition_drivers(
364
366
  }
365
367
 
366
368
  # magnitude metrics
367
- if "complaints_to_date" in from_row.index:
368
- count_col_name = "complaints_to_date"
369
- elif "count_to_date" in from_row.index:
370
- count_col_name = "count_to_date"
369
+ if "cumulative_count" in from_row.index:
370
+ count_col_name = "cumulative_count"
371
371
  else:
372
372
  count_col_name = None
373
373
 
@@ -454,7 +454,7 @@ def extract_transition_drivers(
454
454
  from_period_data,
455
455
  to_period_data,
456
456
  effective_subcats,
457
- period_increase,
457
+ absolute_delta,
458
458
  top_n=top_n_subcategories,
459
459
  min_delta=min_subcategory_delta,
460
460
  )
@@ -467,6 +467,7 @@ def extract_transition_drivers(
467
467
 
468
468
  return result
469
469
 
470
+
470
471
  def _summarize_spike_drivers(priority_info: Dict, magnitude: Dict) -> Dict:
471
472
  """Create a compact summary describing what triggered spike flags."""
472
473
 
@@ -514,7 +515,7 @@ def _analyze_subcategory_drivers(
514
515
  from_data: pd.DataFrame,
515
516
  to_data: pd.DataFrame,
516
517
  subcategory_cols: List[str],
517
- period_increase: int,
518
+ absolute_delta: int,
518
519
  top_n: int = 3,
519
520
  min_delta: int = 1,
520
521
  ) -> Dict:
@@ -556,9 +557,10 @@ def _analyze_subcategory_drivers(
556
557
  ).fillna(0)
557
558
 
558
559
  comparison["delta"] = comparison["count_to"] - comparison["count_from"]
559
- comparison["percent_of_change"] = (
560
- (comparison["delta"] / period_increase * 100) if period_increase > 0 else 0
561
- )
560
+ if absolute_delta > 0:
561
+ comparison["percent_of_change"] = comparison["delta"] / absolute_delta * 100
562
+ else:
563
+ comparison["percent_of_change"] = 0.0
562
564
 
563
565
  # classify driver type
564
566
  def classify_driver_type(row):
@@ -588,12 +590,13 @@ def _analyze_subcategory_drivers(
588
590
  }
589
591
  )
590
592
 
591
- # calculate explanation power
592
- top_explain_pct = (
593
- sum([d["delta"] for d in drivers_list]) / period_increase * 100
594
- if period_increase > 0
595
- else 0
596
- )
593
+ # calculate explanation power (share of cumulative volume delta)
594
+ if absolute_delta > 0:
595
+ top_explain_pct = (
596
+ sum(d["delta"] for d in drivers_list) / absolute_delta * 100
597
+ )
598
+ else:
599
+ top_explain_pct = 0.0
597
600
 
598
601
  drivers_by_subcategory[subcat_col] = {
599
602
  "top_drivers": drivers_list,
@@ -604,92 +607,69 @@ def _analyze_subcategory_drivers(
604
607
 
605
608
 
606
609
  def display_transition_drivers(analysis: Dict) -> None:
607
- """
608
- Print transition driver analysis in human-readable format.
610
+ """Print a concise, diagnoser-style summary of transition drivers."""
609
611
 
610
- Args:
611
- analysis: Output from extract_transition_drivers()
612
-
613
- Examples:
614
- >>> display_transition_drivers(analysis)
615
- """
616
612
  trans = analysis["transition"]
617
613
  mag = analysis["magnitude"]
618
614
  vol = mag["volume_change"]
619
615
  growth = mag["growth_change"]
620
616
  priority = analysis["priority"]
617
+ spike = analysis.get("spike_drivers", {})
618
+ subcats = analysis.get("subcategory_drivers", {})
621
619
 
622
620
  print()
623
621
  print("TRANSITION DRIVER ANALYSIS")
624
622
 
625
- # transition overview
626
- print()
627
- print(f"Entity: {trans['entity']}")
628
- print(f"Period: {trans['from_quarter']} -> {trans['to_quarter']}")
629
- print(f"Quadrant: {trans.get('from_quadrant_label', trans['from_quadrant'])} -> {trans.get('to_quadrant_label', trans['to_quadrant'])}")
630
- if trans.get("risk_level_change"):
631
- print(f"Risk level: {trans['risk_level_change']}")
632
-
633
- if trans["quadrant_changed"]:
634
- print("Status: Quadrant transition detected")
635
- else:
636
- print("Status: Within-quadrant movement")
637
-
638
- # priority
639
- print()
640
- print(f"Priority: P{priority['priority']} ({priority['priority_name']})")
641
- print(f"Reason: {priority['trigger_reason']}")
642
- if priority.get("spike_axis"):
643
- print(f"Spike axis: {priority['spike_axis']}")
644
- if analysis.get("spike_drivers"):
645
- spike = analysis["spike_drivers"]
646
- print("Spike summary:")
647
- for note in spike.get("notes", []):
648
- print(f" - {note}")
649
-
650
- # magnitude
651
- print()
652
- print("Magnitude:")
623
+ # headline
653
624
  print(
654
- f" Volume: {vol['count_from']:,} -> {vol['count_to']:,} "
655
- f"(delta {vol['absolute_delta']:+,}, {vol['percent_change']:+.1f}%)"
625
+ f"[P{priority['priority']}] {trans['entity']}: "
626
+ f"{trans['from_quarter']} -> {trans['to_quarter']} "
627
+ f"({trans.get('from_quadrant', '')} -> {trans.get('to_quadrant', '')})"
656
628
  )
629
+ if trans.get("risk_level_change"):
630
+ print(f" Risk: {trans['risk_level_change']}")
631
+
632
+ # core metrics
657
633
  print(
658
- f" X-axis: {vol['x_from']:.2f} -> {vol['x_to']:.2f} (delta {vol['x_delta']:+.2f})"
634
+ " Cumulative volume (all time): "
635
+ f"{vol['count_from']:,} -> {vol['count_to']:,} "
636
+ f"(Δ {vol['absolute_delta']:+,}, {vol['percent_change']:+.1f}%)"
659
637
  )
660
638
  print(
661
- f" Y-axis: {growth['y_from']:.2f} -> {growth['y_to']:.2f} (delta {growth['y_delta']:+.2f})"
639
+ " X/Y: "
640
+ f"X {vol['x_from']:.2f} -> {vol['x_to']:.2f} (Δ {vol['x_delta']:+.2f}), "
641
+ f"Y {growth['y_from']:.2f} -> {growth['y_to']:.2f} (Δ {growth['y_delta']:+.2f})"
662
642
  )
663
- if "weekly_avg_from" in growth:
643
+ if "period_counts" in mag:
644
+ pc = mag["period_counts"]
664
645
  print(
665
- f" Weekly avg: {growth['weekly_avg_from']:.1f} -> {growth['weekly_avg_to']:.1f}"
646
+ " Period volume (quarter): "
647
+ f"{pc['count_from']} -> {pc['count_to']} "
648
+ f"(Δ {pc['absolute_delta']:+})"
666
649
  )
667
650
 
668
- if "period_counts" in mag:
669
- period_counts = mag["period_counts"]
670
- print(
671
- " Period complaints: "
672
- f"{period_counts['count_from']} -> {period_counts['count_to']} "
673
- f"(delta {period_counts['absolute_delta']:+})"
651
+ # priority + spike summary
652
+ print(f" Priority: P{priority['priority']} ({priority['priority_name']})")
653
+ print(f" Trigger: {priority['trigger_reason']}")
654
+
655
+ for note in spike.get("notes", []):
656
+ print(f" · {note}")
657
+
658
+ # subcategory drivers (if available)
659
+ for subcat_col, drivers_data in subcats.items():
660
+ top_drivers = drivers_data.get("top_drivers", [])
661
+ explain_pct = drivers_data.get(
662
+ "top_n_explain_pct", drivers_data.get("top_3_explain_pct", 0.0)
674
663
  )
664
+ if not top_drivers:
665
+ continue
675
666
 
676
- # subcategory drivers
677
- if "subcategory_drivers" in analysis:
678
- for subcat_col, drivers_data in analysis["subcategory_drivers"].items():
679
- top_drivers = drivers_data["top_drivers"]
680
- explain_pct = drivers_data.get("top_n_explain_pct", drivers_data.get("top_3_explain_pct", 0.0))
681
-
682
- if top_drivers:
683
- print()
684
- print(
685
- f"Top drivers by {subcat_col} (explain {explain_pct:.1f}% of change):"
686
- )
687
- for i, driver in enumerate(top_drivers, 1):
688
- print(f" {i}. {driver['name']}")
689
- print(
690
- f" {driver['count_from']:,} -> {driver['count_to']:,} "
691
- f"(delta {driver['delta']:+,}, {driver['percent_of_change']:.1f}%)"
692
- )
693
- print(f" Type: {driver['driver_type']}")
667
+ print(f" Drivers by {subcat_col} (explain {explain_pct:.1f}% of change):")
668
+ for d in top_drivers:
669
+ print(
670
+ f" - {d['name']}: "
671
+ f"{d['count_from']:,} -> {d['count_to']:,} "
672
+ f"(Δ {d['delta']:+,}, {d['percent_of_change']:.1f}%, {d['driver_type']})"
673
+ )
694
674
 
695
- print()
675
+ print() # trailing newline for readability
@@ -5,6 +5,8 @@ from typing import Optional, Sequence, Union
5
5
 
6
6
  import pandas as pd
7
7
 
8
+ from priorityx.utils.helpers import latest_artifact_csv, save_dataframe_to_csv
9
+
8
10
 
9
11
  def _is_quarter_start(ts: pd.Timestamp) -> bool:
10
12
  """Check if timestamp is the first day of a calendar quarter."""
@@ -216,8 +218,9 @@ def track_cumulative_movement(
216
218
  Returns:
217
219
  Tuple of (movement_df, metadata_dict)
218
220
  - movement_df: Quarter-by-quarter tracking with columns:
219
- [entity, quarter, count_to_date, period_x, period_y, period_quadrant,
220
- global_quadrant, global_x, global_y, count_total, x_delta, y_delta, volume_delta]
221
+ [entity, quarter, period_count, cumulative_count, period_x, period_y,
222
+ period_quadrant, global_quadrant, global_x, global_y,
223
+ count_total, x_delta, y_delta, quadrant_differs]
221
224
  - metadata_dict: Tracking statistics and configuration
222
225
 
223
226
  Examples:
@@ -245,7 +248,8 @@ def track_cumulative_movement(
245
248
  columns=[
246
249
  "entity",
247
250
  "quarter",
248
- "count_to_date",
251
+ "period_count",
252
+ "cumulative_count",
249
253
  "period_x",
250
254
  "period_y",
251
255
  "period_quadrant",
@@ -253,8 +257,12 @@ def track_cumulative_movement(
253
257
  "global_x",
254
258
  "global_y",
255
259
  "count_total",
260
+ "x_delta",
261
+ "y_delta",
262
+ "quadrant_differs",
256
263
  ]
257
264
  )
265
+
258
266
  metadata = {
259
267
  "entities_tracked": 0,
260
268
  "quarters_analyzed": 0,
@@ -395,7 +403,7 @@ def track_cumulative_movement(
395
403
  {
396
404
  "entity": entity,
397
405
  "quarter": quarter_name,
398
- "count_to_date": row["count"],
406
+ "cumulative_count": row["count"],
399
407
  "period_x": row["Random_Intercept"],
400
408
  "period_y": row["Random_Slope"],
401
409
  "period_quadrant": row["quadrant"],
@@ -419,7 +427,8 @@ def track_cumulative_movement(
419
427
  columns=[
420
428
  "entity",
421
429
  "quarter",
422
- "count_to_date",
430
+ "period_count",
431
+ "cumulative_count",
423
432
  "period_x",
424
433
  "period_y",
425
434
  "period_quadrant",
@@ -429,7 +438,7 @@ def track_cumulative_movement(
429
438
  "count_total",
430
439
  "x_delta",
431
440
  "y_delta",
432
- "volume_delta",
441
+ "quadrant_differs",
433
442
  ]
434
443
  )
435
444
 
@@ -442,13 +451,40 @@ def track_cumulative_movement(
442
451
  # calculate deltas
443
452
  movement_df["x_delta"] = movement_df.groupby("entity")["period_x"].diff()
444
453
  movement_df["y_delta"] = movement_df.groupby("entity")["period_y"].diff()
445
- movement_df["volume_delta"] = movement_df.groupby("entity")["count_to_date"].diff()
454
+
455
+ # new event count per period
456
+ movement_df["period_count"] = movement_df.groupby("entity")[
457
+ "cumulative_count"
458
+ ].diff()
446
459
 
447
460
  # detect quadrant divergence
448
461
  movement_df["quadrant_differs"] = (
449
462
  movement_df["period_quadrant"] != movement_df["global_quadrant"]
450
463
  )
451
464
 
465
+ # reorder columns for readability
466
+ cols = [
467
+ "entity",
468
+ "quarter",
469
+ "period_count",
470
+ "cumulative_count",
471
+ "period_x",
472
+ "period_y",
473
+ "period_quadrant",
474
+ "global_quadrant",
475
+ "global_x",
476
+ "global_y",
477
+ "count_total",
478
+ "x_delta",
479
+ "y_delta",
480
+ "quadrant_differs",
481
+ ]
482
+ # keep any extra columns at the end
483
+ movement_df = movement_df[
484
+ [c for c in cols if c in movement_df.columns]
485
+ + [c for c in movement_df.columns if c not in cols]
486
+ ]
487
+
452
488
  print(f"Tracked {len(movement_df)} entity-quarter observations")
453
489
  print(f"Entities tracked: {movement_df['entity'].nunique()}")
454
490
  print(f"Periods covered: {movement_df['quarter'].nunique()}")
@@ -472,3 +508,49 @@ def track_cumulative_movement(
472
508
  }
473
509
 
474
510
  return movement_df, metadata
511
+
512
+
513
+ def load_or_track_movement(
514
+ df_raw: pd.DataFrame,
515
+ *,
516
+ entity_name: str,
517
+ entity_col: str,
518
+ timestamp_col: str,
519
+ quarters,
520
+ min_total_count: int,
521
+ temporal_granularity: str = "quarterly",
522
+ output_dir: str = "results/csv",
523
+ use_cache: bool = True,
524
+ ) -> tuple[pd.DataFrame, str | None]:
525
+ """Load latest movement CSV for entity or compute and save.
526
+
527
+ When ``use_cache`` is True, tries to load the most recent
528
+ ``movement-<entity_slug>-*.csv`` from ``output_dir``. If not
529
+ found, runs tracking and saves a fresh movement CSV.
530
+ """
531
+
532
+ csv_path: str | None = None
533
+ if use_cache:
534
+ csv_path = latest_artifact_csv("movement", entity_name, output_dir)
535
+ if csv_path:
536
+ movement_df = pd.read_csv(csv_path)
537
+ return movement_df, csv_path
538
+
539
+ movement_df, _meta = track_cumulative_movement(
540
+ df_raw,
541
+ entity_col=entity_col,
542
+ timestamp_col=timestamp_col,
543
+ quarters=quarters,
544
+ min_total_count=min_total_count,
545
+ temporal_granularity=temporal_granularity,
546
+ )
547
+
548
+ csv_path = save_dataframe_to_csv(
549
+ movement_df,
550
+ artifact="movement",
551
+ entity_name=entity_name,
552
+ temporal_granularity=temporal_granularity,
553
+ output_dir=output_dir,
554
+ )
555
+
556
+ return movement_df, csv_path
@@ -2,6 +2,7 @@
2
2
 
3
3
  import os
4
4
  from datetime import datetime
5
+ from pathlib import Path
5
6
 
6
7
  import pandas as pd
7
8
 
@@ -298,3 +299,21 @@ def save_dataframe_to_csv(
298
299
  )
299
300
  df.to_csv(output_path, index=False)
300
301
  return output_path
302
+
303
+
304
+ def latest_artifact_csv(
305
+ artifact: str,
306
+ entity_name: str,
307
+ output_dir: str = "results/csv",
308
+ ) -> str | None:
309
+ """Find the latest CSV for a given artifact/entity pair.
310
+
311
+ Assumes filenames follow ``generate_output_path`` convention:
312
+ ``<output_dir>/<artifact>-<entity_slug>-<granularity>-<YYYYMMDD>.csv``.
313
+ """
314
+
315
+ slug = entity_name.lower().replace(" ", "_")
316
+ directory = Path(output_dir)
317
+ pattern = f"{artifact}-{slug}-*.csv"
318
+ matches = sorted(directory.glob(pattern))
319
+ return str(matches[-1]) if matches else None
@@ -222,32 +222,35 @@ def plot_transition_timeline(
222
222
  if len(prev_quarter_data) > 0:
223
223
  prev_row = prev_quarter_data.iloc[-1]
224
224
 
225
- # get count column (handle both naming conventions)
226
- count_col = (
227
- "complaints_to_date"
228
- if "complaints_to_date" in row.index
229
- else "count_to_date"
230
- )
231
-
232
- complaints_delta = int(row[count_col] - prev_row[count_col])
233
- percent_change = (
234
- (row[count_col] - prev_row[count_col])
235
- / prev_row[count_col]
236
- * 100
237
- if prev_row[count_col] > 0
238
- else 0
239
- )
240
-
241
- priority, _, spike_axis = classify_priority(
242
- from_quadrant=transition["from_quadrant"],
243
- to_quadrant=transition["to_quadrant"],
244
- x=row["period_x"],
245
- y=row["period_y"],
246
- x_delta=row["period_x"] - prev_row["period_x"],
247
- y_delta=row["period_y"] - prev_row["period_y"],
248
- complaints_delta=complaints_delta,
249
- percent_change=percent_change,
250
- )
225
+ # use cumulative_count for movement magnitude when available
226
+ if (
227
+ "cumulative_count" in row.index
228
+ and "cumulative_count" in prev_row.index
229
+ ):
230
+ count_col = "cumulative_count"
231
+
232
+ complaints_delta = int(row[count_col] - prev_row[count_col])
233
+ percent_change = (
234
+ (row[count_col] - prev_row[count_col])
235
+ / prev_row[count_col]
236
+ * 100
237
+ if prev_row[count_col] > 0
238
+ else 0
239
+ )
240
+
241
+ priority, _, spike_axis = classify_priority(
242
+ from_quadrant=transition["from_quadrant"],
243
+ to_quadrant=transition["to_quadrant"],
244
+ x=row["period_x"],
245
+ y=row["period_y"],
246
+ x_delta=row["period_x"] - prev_row["period_x"],
247
+ y_delta=row["period_y"] - prev_row["period_y"],
248
+ complaints_delta=complaints_delta,
249
+ percent_change=percent_change,
250
+ )
251
+ else:
252
+ priority = 2
253
+ spike_axis = None
251
254
  else:
252
255
  priority = 2 # default to "investigate"
253
256
  else:
@@ -15,6 +15,7 @@ warnings.filterwarnings(
15
15
  category=UserWarning,
16
16
  )
17
17
 
18
+
18
19
  def plot_entity_trajectories(
19
20
  movement_df: pd.DataFrame,
20
21
  entity_name: str = "Entity",
@@ -69,6 +70,25 @@ def plot_entity_trajectories(
69
70
  print("No movement data to visualize")
70
71
  return None
71
72
 
73
+ # trim trajectories after last quarter with new events
74
+ if "period_count" in movement_df.columns:
75
+ df_trim = movement_df.copy()
76
+ active = df_trim[df_trim["period_count"].fillna(0) > 0]
77
+ if not active.empty:
78
+ last_active = active.groupby("entity")["quarter"].max()
79
+ df_trim = df_trim.merge(
80
+ last_active.rename("last_active_quarter"),
81
+ on="entity",
82
+ how="left",
83
+ )
84
+ # keep only entities that ever had positive period_count
85
+ # and cut them at their last active quarter
86
+ df_trim = df_trim[
87
+ df_trim["last_active_quarter"].notna()
88
+ & (df_trim["quarter"] <= df_trim["last_active_quarter"])
89
+ ]
90
+ movement_df = df_trim
91
+
72
92
  # select entities to plot
73
93
  if highlight_entities:
74
94
  entities_to_plot = [
@@ -129,11 +149,11 @@ def plot_entity_trajectories(
129
149
 
130
150
  # get color from global quadrant
131
151
  global_quad = entity_data.iloc[0]["global_quadrant"]
132
-
152
+
133
153
  # find alternative color if already used
134
154
  base_color = colors.get(global_quad, "#95a5a6")
135
155
  color = base_color
136
-
156
+
137
157
  used_colors[base_color] = used_colors.get(base_color, 0) + 1
138
158
  if used_colors[base_color] > 1:
139
159
  # generate darker shade for same quadrant (better contrast)
@@ -243,7 +263,13 @@ def plot_entity_trajectories(
243
263
  from matplotlib.lines import Line2D
244
264
 
245
265
  quadrant_legend = [
246
- Line2D([0], [0], color=colors[quadrant], linewidth=3, label=quadrant_display[quadrant])
266
+ Line2D(
267
+ [0],
268
+ [0],
269
+ color=colors[quadrant],
270
+ linewidth=3,
271
+ label=quadrant_display[quadrant],
272
+ )
247
273
  for quadrant in ["Q1", "Q2", "Q3", "Q4"]
248
274
  ]
249
275
  legend_fontsize = 15
@@ -257,11 +283,11 @@ def plot_entity_trajectories(
257
283
  )
258
284
 
259
285
  # remove plot borders for cleaner look
260
- ax.spines['top'].set_visible(False)
261
- ax.spines['right'].set_visible(False)
262
- ax.spines['bottom'].set_visible(False)
263
- ax.spines['left'].set_visible(False)
264
-
286
+ ax.spines["top"].set_visible(False)
287
+ ax.spines["right"].set_visible(False)
288
+ ax.spines["bottom"].set_visible(False)
289
+ ax.spines["left"].set_visible(False)
290
+
265
291
  # keep quadrant dividers but make them more subtle
266
292
  ax.axhline(0, color="grey", linestyle="--", alpha=0.7, linewidth=1, zorder=0)
267
293
  ax.axvline(0, color="grey", linestyle="--", alpha=0.7, linewidth=1, zorder=0)
@@ -63,7 +63,7 @@ def test_extract_transition_drivers_basic():
63
63
  "period_quadrant": ["Q3", "Q2"],
64
64
  "period_x": [-0.2, -0.1],
65
65
  "period_y": [-0.1, 0.2],
66
- "count_to_date": [50, 80],
66
+ "cumulative_count": [50, 80],
67
67
  }
68
68
  )
69
69
 
@@ -136,7 +136,7 @@ def test_extract_transition_drivers_manual_subcategory_controls():
136
136
  "period_quadrant": ["Q3", "Q2"],
137
137
  "period_x": [-0.3, -0.05],
138
138
  "period_y": [-0.2, 0.3],
139
- "complaints_to_date": [40, 90],
139
+ "cumulative_count": [40, 90],
140
140
  }
141
141
  )
142
142
 
@@ -205,7 +205,7 @@ def test_extract_transition_drivers_fallback_detection():
205
205
  "period_quadrant": ["Q4", "Q2"],
206
206
  "period_x": [-0.4, 0.1],
207
207
  "period_y": [-0.2, 0.3],
208
- "complaints_to_date": [30, 65],
208
+ "cumulative_count": [30, 65],
209
209
  }
210
210
  )
211
211
 
@@ -2270,7 +2270,7 @@ wheels = [
2270
2270
 
2271
2271
  [[package]]
2272
2272
  name = "priorityx"
2273
- version = "0.2.2"
2273
+ version = "0.2.3"
2274
2274
  source = { editable = "." }
2275
2275
  dependencies = [
2276
2276
  { name = "adjusttext" },
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes