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.
- {priorityx-0.2.3 → priorityx-0.2.5}/PKG-INFO +1 -1
- {priorityx-0.2.3 → priorityx-0.2.5}/pyproject.toml +1 -1
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/__init__.py +1 -1
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/tracking/drivers.py +65 -85
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/tracking/movement.py +89 -7
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/utils/helpers.py +19 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/viz/timeline.py +29 -26
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/viz/trajectory.py +34 -8
- {priorityx-0.2.3 → priorityx-0.2.5}/tests/test_drivers.py +3 -3
- {priorityx-0.2.3 → priorityx-0.2.5}/uv.lock +1 -1
- {priorityx-0.2.3 → priorityx-0.2.5}/.github/workflows/release.yml +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/.github/workflows/test.yml +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/.gitignore +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/LICENSE +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/Makefile +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/README.md +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/docs/api-reference.md +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/docs/methodology.md +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/docs/priority_classification.md +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/docs/quickstart.md +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/examples/README.md +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/examples/bugs/bugs_monitoring.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/examples/incidents/incident_monitoring.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/examples/violations/violations_monitoring.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/scripts/version.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/core/__init__.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/core/filters.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/core/glmm.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/core/quadrants.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/tracking/__init__.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/tracking/transitions.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/utils/__init__.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/viz/__init__.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/src/priorityx/viz/matrix.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/tests/test_glmm.py +0 -0
- {priorityx-0.2.3 → priorityx-0.2.5}/tests/test_quadrants.py +0 -0
- {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
|
+
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
|
|
@@ -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(
|
|
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 =
|
|
266
|
-
min_subcategory_delta: int =
|
|
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 "
|
|
368
|
-
count_col_name = "
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
560
|
-
|
|
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
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
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
|
-
#
|
|
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"
|
|
655
|
-
f"
|
|
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
|
-
|
|
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
|
-
|
|
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 "
|
|
643
|
+
if "period_counts" in mag:
|
|
644
|
+
pc = mag["period_counts"]
|
|
664
645
|
print(
|
|
665
|
-
|
|
646
|
+
" Period volume (quarter): "
|
|
647
|
+
f"{pc['count_from']} -> {pc['count_to']} "
|
|
648
|
+
f"(Δ {pc['absolute_delta']:+})"
|
|
666
649
|
)
|
|
667
650
|
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
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,
|
|
220
|
-
global_quadrant, global_x, global_y,
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
#
|
|
226
|
-
|
|
227
|
-
"
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
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(
|
|
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[
|
|
261
|
-
ax.spines[
|
|
262
|
-
ax.spines[
|
|
263
|
-
ax.spines[
|
|
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
|
-
"
|
|
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
|
-
"
|
|
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
|
-
"
|
|
208
|
+
"cumulative_count": [30, 65],
|
|
209
209
|
}
|
|
210
210
|
)
|
|
211
211
|
|
|
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
|
|
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
|
|
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
|