priorityx 0.2.5__tar.gz → 0.2.6__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 (39) hide show
  1. {priorityx-0.2.5 → priorityx-0.2.6}/PKG-INFO +1 -1
  2. priorityx-0.2.6/docs/README.md +15 -0
  3. priorityx-0.2.6/docs/output_naming.md +168 -0
  4. {priorityx-0.2.5 → priorityx-0.2.6}/pyproject.toml +1 -1
  5. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/__init__.py +1 -1
  6. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/core/glmm.py +36 -1
  7. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/tracking/movement.py +206 -56
  8. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/utils/helpers.py +18 -1
  9. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/viz/matrix.py +11 -9
  10. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/viz/timeline.py +34 -7
  11. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/viz/trajectory.py +12 -9
  12. {priorityx-0.2.5 → priorityx-0.2.6}/tests/test_glmm.py +17 -0
  13. {priorityx-0.2.5 → priorityx-0.2.6}/uv.lock +1 -1
  14. {priorityx-0.2.5 → priorityx-0.2.6}/.github/workflows/release.yml +0 -0
  15. {priorityx-0.2.5 → priorityx-0.2.6}/.github/workflows/test.yml +0 -0
  16. {priorityx-0.2.5 → priorityx-0.2.6}/.gitignore +0 -0
  17. {priorityx-0.2.5 → priorityx-0.2.6}/LICENSE +0 -0
  18. {priorityx-0.2.5 → priorityx-0.2.6}/Makefile +0 -0
  19. {priorityx-0.2.5 → priorityx-0.2.6}/README.md +0 -0
  20. {priorityx-0.2.5 → priorityx-0.2.6}/docs/api-reference.md +0 -0
  21. {priorityx-0.2.5 → priorityx-0.2.6}/docs/methodology.md +0 -0
  22. {priorityx-0.2.5 → priorityx-0.2.6}/docs/priority_classification.md +0 -0
  23. {priorityx-0.2.5 → priorityx-0.2.6}/docs/quickstart.md +0 -0
  24. {priorityx-0.2.5 → priorityx-0.2.6}/examples/README.md +0 -0
  25. {priorityx-0.2.5 → priorityx-0.2.6}/examples/bugs/bugs_monitoring.py +0 -0
  26. {priorityx-0.2.5 → priorityx-0.2.6}/examples/incidents/incident_monitoring.py +2 -2
  27. {priorityx-0.2.5 → priorityx-0.2.6}/examples/violations/violations_monitoring.py +0 -0
  28. {priorityx-0.2.5 → priorityx-0.2.6}/scripts/version.py +0 -0
  29. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/core/__init__.py +0 -0
  30. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/core/filters.py +0 -0
  31. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/core/quadrants.py +0 -0
  32. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/tracking/__init__.py +0 -0
  33. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/tracking/drivers.py +0 -0
  34. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/tracking/transitions.py +0 -0
  35. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/utils/__init__.py +0 -0
  36. {priorityx-0.2.5 → priorityx-0.2.6}/src/priorityx/viz/__init__.py +0 -0
  37. {priorityx-0.2.5 → priorityx-0.2.6}/tests/test_drivers.py +0 -0
  38. {priorityx-0.2.5 → priorityx-0.2.6}/tests/test_quadrants.py +0 -0
  39. {priorityx-0.2.5 → priorityx-0.2.6}/tests/test_transitions.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: priorityx
3
- Version: 0.2.5
3
+ Version: 0.2.6
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
@@ -0,0 +1,15 @@
1
+ # Docs overview
2
+
3
+ This folder holds the main reference material for `priorityx`.
4
+
5
+ - `quickstart.md` – short walk‑through: install, load data, run a first
6
+ priority matrix and plot it.
7
+ - `api-reference.md` – function‑by‑function notes for the core modules
8
+ (GLMM, movement, transitions, plots, helpers).
9
+ - `methodology.md` – background on the modelling choices: Poisson GLMM,
10
+ time aggregation, movement tracking, and how we think about risk
11
+ quadrants.
12
+ - `priority_classification.md` – how P1–P4 priorities and spike markers
13
+ are defined and used in timelines.
14
+ - `output_naming.md` – how plot/CSV artifacts are named and where they
15
+ are written on disk (e.g. `results/plot`, `results/csv`).
@@ -0,0 +1,168 @@
1
+ % PriorityX Output Naming
2
+
3
+ This note documents where PriorityX writes plots/CSVs and how filenames
4
+ are constructed. It is meant as a quick reference when wiring outputs
5
+ into EWS pipelines or BI tools.
6
+
7
+ ---
8
+
9
+ ## Directories
10
+
11
+ By default, PriorityX writes artifacts under the current working
12
+ directory, using two main folders:
13
+
14
+ - `results/plot/` – PNG plots
15
+ - `results/csv/` – CSV tables (movement, matrices, transitions, etc.)
16
+
17
+ You can override these via the `plot_dir` / `csv_dir` / `output_dir`
18
+ arguments on the plotting and helper functions.
19
+
20
+ ---
21
+
22
+ ## Priority Matrix
23
+
24
+ Function: `priorityx.viz.matrix.plot_priority_matrix`
25
+
26
+ - Default `plot_dir`: `results/plot`
27
+ - Default `csv_dir`: `results/csv`
28
+ - Filename pattern (plot):
29
+
30
+ ```text
31
+ priority_matrix-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.png
32
+ ```
33
+
34
+ - Filename pattern (CSV):
35
+
36
+ ```text
37
+ priority_matrix-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.csv
38
+ ```
39
+
40
+ Where:
41
+
42
+ - `<entity_name_slug>` is `entity_name.lower().replace(" ", "_")`
43
+ - `<granularity_suffix>` is:
44
+ - `Q` for quarterly
45
+ - `Y` for yearly
46
+ - `S` for semiannual
47
+
48
+ Example (topics, quarterly):
49
+
50
+ ```text
51
+ results/plot/priority_matrix-topic-Q-20251126.png
52
+ results/csv/priority_matrix-topic-Q-20251126.csv
53
+ ```
54
+
55
+ ---
56
+
57
+ ## Transition Timelines
58
+
59
+ Function: `priorityx.viz.timeline.plot_transition_timeline`
60
+
61
+ - Default `plot_dir`: `results/plot`
62
+ - Default `csv_dir`: `results/csv`
63
+ - Filename pattern (plot):
64
+
65
+ ```text
66
+ transition_timeline-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.png
67
+ ```
68
+
69
+ - Filename pattern (CSV):
70
+
71
+ ```text
72
+ transition_timeline-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.csv
73
+ ```
74
+
75
+ Example (FSPs, quarterly):
76
+
77
+ ```text
78
+ results/plot/transition_timeline-fsp-Q-20251126.png
79
+ results/csv/transition_timeline-fsp-Q-20251126.csv
80
+ ```
81
+
82
+ ---
83
+
84
+ ## Trajectories
85
+
86
+ Function: `priorityx.viz.trajectory.plot_entity_trajectories`
87
+
88
+ - Default `plot_dir`: `results/plot`
89
+ - Default `csv_dir`: `results/csv`
90
+ - Filename pattern (plot):
91
+
92
+ ```text
93
+ trajectories-<entity_name_slug>-<granularity_suffix>-<YYYYMMDD>.png
94
+ ```
95
+
96
+ - When `save_csv=True` (default is `False`), CSV is saved via
97
+ `priorityx.utils.helpers.save_dataframe_to_csv` with artifact name
98
+ `"trajectories"` (see next section).
99
+
100
+ Example (topics, quarterly):
101
+
102
+ ```text
103
+ results/plot/trajectories-topic-Q-20251126.png
104
+ results/csv/trajectories-topic-Q-20251126.csv # only when save_csv=True
105
+ ```
106
+
107
+ ---
108
+
109
+ ## Helpers: CSV Naming
110
+
111
+ Utility: `priorityx.utils.helpers.save_dataframe_to_csv`
112
+
113
+ This helper centralises CSV naming using `generate_output_path`:
114
+
115
+ ```text
116
+ <output_dir>/<artifact>-<entity_slug>-<granularity_suffix>-<YYYYMMDD>.csv
117
+ ```
118
+
119
+ Where:
120
+
121
+ - `<output_dir>` defaults to `results/csv`
122
+ - `<artifact>` is a short key such as `movement`, `trajectories`,
123
+ `priority_matrix`
124
+ - `<entity_slug>` is `entity_name.lower().replace(" ", "_")`
125
+ - `<granularity_suffix>` is `Q`, `Y`, or `S` (as above)
126
+
127
+ Examples:
128
+
129
+ ```text
130
+ results/csv/movement-topic-Q-20251126.csv
131
+ results/csv/trajectories-fsp-Q-20251126.csv
132
+ ```
133
+
134
+ ### EWS canonical CSV outputs
135
+
136
+ For the complaints Early Warning System (EWS) pipelines, the canonical
137
+ CSV artifacts per run are:
138
+
139
+ ```text
140
+ movement-*.csv
141
+ priority_matrix-*.csv
142
+ transitions-*.csv
143
+ ```
144
+
145
+ Trajectory CSVs (`trajectories-*.csv`) are optional and are typically
146
+ only written for selected custom views (for example, a focus list of
147
+ FSPs or products) by explicitly passing `save_csv=True` to
148
+ `plot_entity_trajectories`.
149
+
150
+ ---
151
+
152
+ ## Overriding locations
153
+
154
+ All plotting and helper functions accept directories as keyword
155
+ arguments (`plot_dir`, `csv_dir`, `output_dir`). To write outputs into a
156
+ custom EWS workspace, pass explicit paths, for example:
157
+
158
+ ```python
159
+ plot_priority_matrix(
160
+ results_df,
161
+ entity_name="Topic",
162
+ plot_dir="ews/results/plot",
163
+ csv_dir="ews/results/csv",
164
+ )
165
+ ```
166
+
167
+ This keeps file naming consistent while letting each project choose
168
+ where artifacts live.
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "priorityx"
3
- version = "0.2.5"
3
+ version = "0.2.6"
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.5"
3
+ __version__ = "0.2.6"
4
4
 
5
5
  # API will be imported
6
6
 
@@ -69,7 +69,7 @@ def fit_priority_matrix(
69
69
  min_observations: int = 3,
70
70
  min_total_count: int = 0,
71
71
  decline_window_quarters: int = 6,
72
- temporal_granularity: Literal["yearly", "quarterly", "semiannual"] = "yearly",
72
+ temporal_granularity: Literal["yearly", "quarterly", "semiannual", "monthly"] = "yearly",
73
73
  vcp_p: float = DEFAULT_VCP_P,
74
74
  fe_p: float = DEFAULT_FE_P,
75
75
  verbose: bool = False,
@@ -215,6 +215,25 @@ def fit_priority_matrix(
215
215
  .sort_values(["year", "semester", entity_col])
216
216
  )
217
217
 
218
+ elif temporal_granularity == "monthly":
219
+ df["year"] = df[timestamp_col].dt.year
220
+ df["month"] = df[timestamp_col].dt.month
221
+
222
+ if count_col:
223
+ df_prepared = (
224
+ df.groupby(["year", "month", entity_col])[count_col]
225
+ .sum()
226
+ .reset_index(name="count")
227
+ .sort_values(["year", "month", entity_col])
228
+ )
229
+ else:
230
+ df_prepared = (
231
+ df.groupby(["year", "month", entity_col])
232
+ .size()
233
+ .reset_index(name="count")
234
+ .sort_values(["year", "month", entity_col])
235
+ )
236
+
218
237
  else: # yearly
219
238
  df["year"] = df[timestamp_col].dt.year
220
239
 
@@ -267,6 +286,16 @@ def fit_priority_matrix(
267
286
  mean_time = df_prepared["time_continuous"].mean()
268
287
  df_prepared["time"] = df_prepared["time_continuous"] - mean_time
269
288
 
289
+ elif temporal_granularity == "monthly":
290
+ # continuous monthly time: year + (month-1)/12
291
+ df_prepared["time_continuous"] = (
292
+ df_prepared["year"] + (df_prepared["month"] - 1) / 12
293
+ )
294
+
295
+ # center for numerical stability
296
+ mean_time = df_prepared["time_continuous"].mean()
297
+ df_prepared["time"] = df_prepared["time_continuous"] - mean_time
298
+
270
299
  else: # yearly
271
300
  # center year for numerical stability
272
301
  mean_year = df_prepared["year"].mean()
@@ -280,6 +309,8 @@ def fit_priority_matrix(
280
309
  df_prepared["quarter"] = df_prepared["quarter"].astype("category")
281
310
  elif temporal_granularity == "semiannual":
282
311
  df_prepared["semester"] = df_prepared["semester"].astype("category")
312
+ elif temporal_granularity == "monthly":
313
+ df_prepared["month"] = df_prepared["month"].astype("category")
283
314
 
284
315
  # ensure positive counts for poisson
285
316
  df_prepared = df_prepared[df_prepared["count"] > 0]
@@ -296,10 +327,14 @@ def fit_priority_matrix(
296
327
  formula += " + C(quarter)"
297
328
  elif temporal_granularity == "semiannual" and n_years >= 2:
298
329
  formula += " + C(semester)"
330
+ elif temporal_granularity == "monthly" and n_years >= 2:
331
+ formula += " + C(month)"
299
332
  elif temporal_granularity == "quarterly" and n_years == 1:
300
333
  print(" Warning: Single-year quarterly data detected, skipping seasonal dummies to avoid multicollinearity")
301
334
  elif temporal_granularity == "semiannual" and n_years == 1:
302
335
  print(" Warning: Single-year semiannual data detected, skipping seasonal dummies to avoid multicollinearity")
336
+ elif temporal_granularity == "monthly" and n_years == 1:
337
+ print(" Warning: Single-year monthly data detected, skipping seasonal dummies to avoid multicollinearity")
303
338
 
304
339
  # random effects: intercept + slope per entity
305
340
  random_formulas = {
@@ -44,11 +44,32 @@ def _quarter_label(ts: pd.Timestamp) -> str:
44
44
  return f"{ts.year}-Q{quarter}"
45
45
 
46
46
 
47
+ def _is_month_start(ts: pd.Timestamp) -> bool:
48
+ """Check if timestamp is the first day of a calendar month."""
49
+
50
+ return ts.day == 1
51
+
52
+
53
+ def _next_month_start(ts: pd.Timestamp) -> pd.Timestamp:
54
+ """Get timestamp for the first day of the next calendar month."""
55
+
56
+ year = ts.year
57
+ month = ts.month
58
+ if month == 12:
59
+ return pd.Timestamp(year=year + 1, month=1, day=1)
60
+ return pd.Timestamp(year=year, month=month + 1, day=1)
61
+
62
+
63
+ def _month_label(ts: pd.Timestamp) -> str:
64
+ """Format timestamp into 'YYYY-MM' label."""
65
+
66
+ return ts.strftime("%Y-%m")
67
+
68
+
47
69
  def _build_quarter_schedule_from_range(
48
70
  start_date: str, end_date: str
49
71
  ) -> list[tuple[str, str]]:
50
- """
51
- Build quarter schedule from date range.
72
+ """Build quarter schedule from date range.
52
73
 
53
74
  Args:
54
75
  start_date: Start date (YYYY-MM-DD), must be quarter boundary
@@ -85,6 +106,44 @@ def _build_quarter_schedule_from_range(
85
106
  return schedule
86
107
 
87
108
 
109
+ def _build_month_schedule_from_range(
110
+ start_date: str, end_date: str
111
+ ) -> list[tuple[str, str]]:
112
+ """Build month schedule from date range.
113
+
114
+ Args:
115
+ start_date: Start date (YYYY-MM-DD), must be month boundary (day=1)
116
+ end_date: End date (YYYY-MM-DD), must be month boundary (day=1)
117
+
118
+ Returns:
119
+ List of (label, exclusive_end_date) tuples
120
+
121
+ Raises:
122
+ ValueError: If dates are not month boundaries or invalid
123
+ """
124
+
125
+ start_ts = pd.Timestamp(start_date)
126
+ end_ts = pd.Timestamp(end_date)
127
+
128
+ if start_ts >= end_ts:
129
+ raise ValueError("Start date must be earlier than end date")
130
+
131
+ if not _is_month_start(start_ts) or not _is_month_start(end_ts):
132
+ raise ValueError("Dates must be month boundaries (YYYY-MM-01)")
133
+
134
+ schedule: list[tuple[str, str]] = []
135
+ current = start_ts
136
+ while current < end_ts:
137
+ next_start = _next_month_start(current)
138
+ if next_start > end_ts:
139
+ next_start = end_ts
140
+
141
+ schedule.append((_month_label(current), next_start.strftime("%Y-%m-%d")))
142
+ current = next_start
143
+
144
+ return schedule
145
+
146
+
88
147
  def _build_default_quarter_schedule(
89
148
  df: pd.DataFrame, timestamp_col: str
90
149
  ) -> list[tuple[str, str]]:
@@ -120,67 +179,116 @@ def _build_default_quarter_schedule(
120
179
  )
121
180
 
122
181
 
123
- def normalize_quarter_schedule(
182
+ def _build_default_month_schedule(
183
+ df: pd.DataFrame, timestamp_col: str
184
+ ) -> list[tuple[str, str]]:
185
+ """Create default month schedule spanning the dataset."""
186
+
187
+ if timestamp_col not in df.columns or len(df) == 0:
188
+ return []
189
+
190
+ df = df.copy()
191
+ df[timestamp_col] = pd.to_datetime(df[timestamp_col])
192
+
193
+ min_date = df[timestamp_col].min()
194
+ max_date = df[timestamp_col].max()
195
+
196
+ if pd.isna(min_date) or pd.isna(max_date):
197
+ return []
198
+
199
+ start_ts = min_date.to_period("M").to_timestamp(how="start")
200
+ max_month_start = max_date.to_period("M").to_timestamp(how="start")
201
+ end_ts = _next_month_start(max_month_start)
202
+
203
+ return _build_month_schedule_from_range(
204
+ start_ts.strftime("%Y-%m-%d"), end_ts.strftime("%Y-%m-%d")
205
+ )
206
+
207
+
208
+ def normalize_period_schedule(
124
209
  quarters: Optional[Sequence[Union[tuple[str, str], str]]],
125
210
  df: pd.DataFrame,
126
211
  timestamp_col: str,
212
+ temporal_granularity: str = "quarterly",
127
213
  ) -> list[tuple[str, str]]:
128
- """
129
- Normalize quarter schedule to standard format.
214
+ """Normalize period schedule to standard (label, cutoff_date) format.
130
215
 
131
- Accepts three input formats:
216
+ For quarterly granularity:
132
217
  1. Explicit schedule: [("2024-Q2", "2024-07-01"), ...]
133
- 2. Date range: ["2024-01-01", "2025-10-01"] (auto-generates quarters)
218
+ 2. Date range: ["2024-01-01", "2025-10-01"]
134
219
  3. None: Auto-detect from data
135
220
 
136
- Args:
137
- quarters: Quarter specification (see formats above)
138
- df: Input pandas DataFrame
139
- timestamp_col: Column name for timestamp
221
+ For monthly granularity, the same patterns apply but labels are
222
+ "YYYY-MM" and dates must be month boundaries.
223
+ """
140
224
 
141
- Returns:
142
- List of (quarter_label, exclusive_end_date) tuples
225
+ if temporal_granularity == "monthly":
226
+ if quarters is None:
227
+ return _build_default_month_schedule(df, timestamp_col)
143
228
 
144
- Examples:
145
- >>> # explicit schedule
146
- >>> normalize_quarter_schedule(
147
- ... [("2024-Q1", "2024-04-01"), ("2024-Q2", "2024-07-01")],
148
- ... df, "date"
149
- ... )
229
+ if len(quarters) == 0:
230
+ return []
150
231
 
151
- >>> # date range (auto-generates quarters)
152
- >>> normalize_quarter_schedule(
153
- ... ["2024-01-01", "2025-01-01"], df, "date"
154
- ... )
232
+ # date range format: ["2024-01-01", "2025-10-01"]
233
+ if len(quarters) == 2 and all(isinstance(q, str) for q in quarters):
234
+ return _build_month_schedule_from_range(quarters[0], quarters[1])
155
235
 
156
- >>> # auto-detect from data
157
- >>> normalize_quarter_schedule(None, df, "date")
158
- """
236
+ normalized_schedule: list[tuple[str, str]] = []
237
+ for entry in quarters:
238
+ if not isinstance(entry, tuple) or len(entry) != 2:
239
+ raise ValueError(
240
+ "quarters must be a list of (label, cutoff_date) tuples "
241
+ "or two date boundary strings",
242
+ )
243
+ label, cutoff = entry
244
+ normalized_schedule.append((label, cutoff))
245
+
246
+ return normalized_schedule
247
+
248
+ # default: quarterly schedule
159
249
  if quarters is None:
160
250
  return _build_default_quarter_schedule(df, timestamp_col)
161
251
 
162
252
  if len(quarters) == 0:
163
253
  return []
164
254
 
165
- # check first element type
166
- quarters[0]
167
-
168
255
  # date range format: ["2024-01-01", "2025-10-01"]
169
256
  if len(quarters) == 2 and all(isinstance(q, str) for q in quarters):
170
257
  return _build_quarter_schedule_from_range(quarters[0], quarters[1])
171
258
 
172
259
  # explicit schedule format: [("2024-Q2", "2024-07-01"), ...]
173
- normalized_schedule: list[tuple[str, str]] = []
260
+ normalized_schedule2: list[tuple[str, str]] = []
174
261
  for entry in quarters:
175
262
  if not isinstance(entry, tuple) or len(entry) != 2:
176
263
  raise ValueError(
177
264
  "quarters must be a list of (label, cutoff_date) tuples "
178
- "or two quarter boundary strings"
265
+ "or two quarter boundary strings",
179
266
  )
180
267
  label, cutoff = entry
181
- normalized_schedule.append((label, cutoff))
268
+ normalized_schedule2.append((label, cutoff))
269
+
270
+ return normalized_schedule2
182
271
 
183
- return normalized_schedule
272
+
273
+ def normalize_quarter_schedule(
274
+ quarters: Optional[Sequence[Union[tuple[str, str], str]]],
275
+ df: pd.DataFrame,
276
+ timestamp_col: str,
277
+ temporal_granularity: str = "quarterly",
278
+ ) -> list[tuple[str, str]]:
279
+ """Backward-compatible alias for :func:`normalize_period_schedule`.
280
+
281
+ Historically this helper only handled quarterly schedules; it now
282
+ delegates to ``normalize_period_schedule`` which supports both
283
+ quarterly and monthly granularities.
284
+ """
285
+
286
+ return normalize_period_schedule(
287
+ quarters,
288
+ df,
289
+ timestamp_col,
290
+ temporal_granularity=temporal_granularity,
291
+ )
184
292
 
185
293
 
186
294
  def track_cumulative_movement(
@@ -208,7 +316,7 @@ def track_cumulative_movement(
208
316
  df: Input pandas DataFrame
209
317
  entity_col: Column name for entity identifier
210
318
  timestamp_col: Column name for timestamp (datetime type)
211
- quarters: Quarter specification (see normalize_quarter_schedule)
319
+ quarters: Period specification (see normalize_period_schedule)
212
320
  min_total_count: Minimum total count for inclusion (default: 20)
213
321
  decline_window_quarters: Max quarters after last observation (default: 6)
214
322
  temporal_granularity: Time granularity for GLMM ('quarterly', 'yearly', 'semiannual')
@@ -217,10 +325,12 @@ def track_cumulative_movement(
217
325
 
218
326
  Returns:
219
327
  Tuple of (movement_df, metadata_dict)
220
- - movement_df: Quarter-by-quarter tracking with columns:
328
+ - movement_df: Period-by-period tracking with columns:
221
329
  [entity, quarter, period_count, cumulative_count, period_x, period_y,
222
330
  period_quadrant, global_quadrant, global_x, global_y,
223
331
  count_total, x_delta, y_delta, quadrant_differs]
332
+ (the ``quarter`` column serves as the canonical period axis
333
+ for both quarterly and monthly granularities)
224
334
  - metadata_dict: Tracking statistics and configuration
225
335
 
226
336
  Examples:
@@ -239,15 +349,22 @@ def track_cumulative_movement(
239
349
  df = df.copy()
240
350
  df[timestamp_col] = pd.to_datetime(df[timestamp_col])
241
351
 
242
- # normalize quarter schedule
243
- quarter_schedule = normalize_quarter_schedule(quarters, df, timestamp_col)
352
+ # normalize quarter/month schedule (generic period schedule)
353
+ period_schedule = normalize_period_schedule(
354
+ quarters,
355
+ df,
356
+ timestamp_col,
357
+ temporal_granularity=temporal_granularity,
358
+ )
359
+
360
+ period_col = "quarter"
244
361
 
245
- if len(quarter_schedule) == 0:
246
- print("Warning: No quarter boundaries available for cumulative tracking")
362
+ if len(period_schedule) == 0:
363
+ print("Warning: No period boundaries available for cumulative tracking")
247
364
  empty_df = pd.DataFrame(
248
365
  columns=[
249
366
  "entity",
250
- "quarter",
367
+ period_col,
251
368
  "period_count",
252
369
  "cumulative_count",
253
370
  "period_x",
@@ -308,8 +425,8 @@ def track_cumulative_movement(
308
425
  # step 2: determine valid entities at analysis endpoint
309
426
  print("\n[2/3] Determining valid entities at analysis endpoint...")
310
427
 
311
- # get endpoint (last quarter)
312
- final_quarter_name, final_quarter_date = quarter_schedule[-1]
428
+ # get endpoint (last period in schedule)
429
+ final_quarter_name, final_quarter_date = period_schedule[-1]
313
430
  print(f"Analysis endpoint: {final_quarter_name} ({final_quarter_date})")
314
431
 
315
432
  # calculate valid entities based on cumulative totals up to endpoint
@@ -360,12 +477,12 @@ def track_cumulative_movement(
360
477
  f"{n_valid_at_endpoint}"
361
478
  )
362
479
 
363
- # step 3: track movement through quarters
364
- print(f"\n[3/3] Tracking movement through {len(quarter_schedule)} quarters...")
480
+ # step 3: track movement through periods
481
+ print(f"\n[3/3] Tracking movement through {len(period_schedule)} periods...")
365
482
 
366
483
  movement_records = []
367
484
 
368
- for quarter_name, end_date_str in quarter_schedule:
485
+ for quarter_name, end_date_str in period_schedule:
369
486
  print(f" [{quarter_name}] Running GLMM on cumulative data...", end=" ")
370
487
 
371
488
  # filter to cumulative data up to this quarter
@@ -402,7 +519,7 @@ def track_cumulative_movement(
402
519
  movement_records.append(
403
520
  {
404
521
  "entity": entity,
405
- "quarter": quarter_name,
522
+ period_col: quarter_name,
406
523
  "cumulative_count": row["count"],
407
524
  "period_x": row["Random_Intercept"],
408
525
  "period_y": row["Random_Slope"],
@@ -426,7 +543,7 @@ def track_cumulative_movement(
426
543
  movement_df = pd.DataFrame(
427
544
  columns=[
428
545
  "entity",
429
- "quarter",
546
+ period_col,
430
547
  "period_count",
431
548
  "cumulative_count",
432
549
  "period_x",
@@ -445,8 +562,8 @@ def track_cumulative_movement(
445
562
  # step 4: calculate movement metrics
446
563
  print("\n[4/4] Calculating movement metrics...")
447
564
 
448
- # add quarter-over-quarter changes
449
- movement_df = movement_df.sort_values(["entity", "quarter"])
565
+ # add period-over-period changes
566
+ movement_df = movement_df.sort_values(["entity", period_col])
450
567
 
451
568
  # calculate deltas
452
569
  movement_df["x_delta"] = movement_df.groupby("entity")["period_x"].diff()
@@ -465,7 +582,7 @@ def track_cumulative_movement(
465
582
  # reorder columns for readability
466
583
  cols = [
467
584
  "entity",
468
- "quarter",
585
+ period_col,
469
586
  "period_count",
470
587
  "cumulative_count",
471
588
  "period_x",
@@ -485,9 +602,9 @@ def track_cumulative_movement(
485
602
  + [c for c in movement_df.columns if c not in cols]
486
603
  ]
487
604
 
488
- print(f"Tracked {len(movement_df)} entity-quarter observations")
605
+ print(f"Tracked {len(movement_df)} entity-period observations")
489
606
  print(f"Entities tracked: {movement_df['entity'].nunique()}")
490
- print(f"Periods covered: {movement_df['quarter'].nunique()}")
607
+ print(f"Periods covered: {movement_df[period_col].nunique()}")
491
608
 
492
609
  divergence_count = movement_df["quadrant_differs"].sum()
493
610
  divergence_pct = (
@@ -500,11 +617,11 @@ def track_cumulative_movement(
500
617
  "entities_tracked": movement_df["entity"].nunique()
501
618
  if not movement_df.empty
502
619
  else 0,
503
- "quarters_analyzed": len(quarter_schedule),
620
+ "quarters_analyzed": len(period_schedule),
504
621
  "total_observations": len(movement_df),
505
622
  "divergence_rate": divergence_pct,
506
623
  "temporal_granularity": temporal_granularity,
507
- "quarter_schedule": quarter_schedule,
624
+ "quarter_schedule": period_schedule,
508
625
  }
509
626
 
510
627
  return movement_df, metadata
@@ -531,9 +648,36 @@ def load_or_track_movement(
531
648
 
532
649
  csv_path: str | None = None
533
650
  if use_cache:
534
- csv_path = latest_artifact_csv("movement", entity_name, output_dir)
651
+ csv_path = latest_artifact_csv(
652
+ "movement",
653
+ entity_name,
654
+ output_dir,
655
+ temporal_granularity=temporal_granularity,
656
+ )
535
657
  if csv_path:
536
658
  movement_df = pd.read_csv(csv_path)
659
+ if temporal_granularity == "monthly":
660
+ # New-style monthly CSVs use a friendlier ``month``
661
+ # column on disk; normalize to ``quarter`` in-memory
662
+ # for compatibility with existing code.
663
+ if (
664
+ "month" in movement_df.columns
665
+ and "quarter" not in movement_df.columns
666
+ ):
667
+ movement_df = movement_df.rename(columns={"month": "quarter"})
668
+
669
+ # Legacy monthly CSVs were written with a ``quarter``
670
+ # column. Treat that as ``month`` for users by
671
+ # rewriting the CSV header to ``month`` while keeping
672
+ # the in-memory schema unchanged.
673
+ elif (
674
+ "quarter" in movement_df.columns
675
+ and "month" not in movement_df.columns
676
+ ):
677
+ month_df = movement_df.rename(columns={"quarter": "month"})
678
+ month_df.to_csv(csv_path, index=False)
679
+ movement_df = month_df.rename(columns={"month": "quarter"})
680
+
537
681
  return movement_df, csv_path
538
682
 
539
683
  movement_df, _meta = track_cumulative_movement(
@@ -545,8 +689,14 @@ def load_or_track_movement(
545
689
  temporal_granularity=temporal_granularity,
546
690
  )
547
691
 
692
+ # For monthly outputs, persist a friendlier "month" label on disk
693
+ # while keeping the in-memory DataFrame schema unchanged.
694
+ movement_to_save = movement_df
695
+ if temporal_granularity == "monthly" and "quarter" in movement_df.columns:
696
+ movement_to_save = movement_df.rename(columns={"quarter": "month"})
697
+
548
698
  csv_path = save_dataframe_to_csv(
549
- movement_df,
699
+ movement_to_save,
550
700
  artifact="movement",
551
701
  entity_name=entity_name,
552
702
  temporal_granularity=temporal_granularity,
@@ -260,6 +260,7 @@ def generate_output_path(
260
260
  "quarterly": "Q",
261
261
  "yearly": "Y",
262
262
  "semiannual": "S",
263
+ "monthly": "M",
263
264
  }.get(temporal_granularity, "Q")
264
265
 
265
266
  timestamp = datetime.now().strftime("%Y%m%d")
@@ -305,15 +306,31 @@ def latest_artifact_csv(
305
306
  artifact: str,
306
307
  entity_name: str,
307
308
  output_dir: str = "results/csv",
309
+ temporal_granularity: str | None = None,
308
310
  ) -> str | None:
309
311
  """Find the latest CSV for a given artifact/entity pair.
310
312
 
311
313
  Assumes filenames follow ``generate_output_path`` convention:
312
314
  ``<output_dir>/<artifact>-<entity_slug>-<granularity>-<YYYYMMDD>.csv``.
315
+
316
+ If ``temporal_granularity`` is provided, restricts matches to that
317
+ granularity suffix (e.g. "Q", "M"); otherwise returns the latest
318
+ CSV across all granularities.
313
319
  """
314
320
 
315
321
  slug = entity_name.lower().replace(" ", "_")
316
322
  directory = Path(output_dir)
317
- pattern = f"{artifact}-{slug}-*.csv"
323
+
324
+ if temporal_granularity is not None:
325
+ granularity_suffix = {
326
+ "quarterly": "Q",
327
+ "yearly": "Y",
328
+ "semiannual": "S",
329
+ "monthly": "M",
330
+ }.get(temporal_granularity, "Q")
331
+ pattern = f"{artifact}-{slug}-{granularity_suffix}-*.csv"
332
+ else:
333
+ pattern = f"{artifact}-{slug}-*.csv"
334
+
318
335
  matches = sorted(directory.glob(pattern))
319
336
  return str(matches[-1]) if matches else None
@@ -8,6 +8,8 @@ import matplotlib.ticker as ticker
8
8
  import pandas as pd
9
9
  from matplotlib.lines import Line2D
10
10
 
11
+ from priorityx.core.quadrants import get_quadrant_label
12
+
11
13
  # try to import adjustText for label positioning
12
14
  try:
13
15
  from adjustText import adjust_text
@@ -65,7 +67,7 @@ def plot_priority_matrix(
65
67
  save_csv: Save data to CSV
66
68
  plot_dir: Output directory for plot files
67
69
  csv_dir: Output directory for CSV files
68
- temporal_granularity: Time granularity ('quarterly', 'yearly', 'semiannual')
70
+ temporal_granularity: Time granularity ('quarterly', 'yearly', 'semiannual', 'monthly')
69
71
  close_fig: Close the figure before returning (set True if you see duplicate inline renders)
70
72
 
71
73
  Returns:
@@ -144,16 +146,14 @@ def plot_priority_matrix(
144
146
 
145
147
  # define colors for each quadrant (tab20 - distinct hues)
146
148
  colors = {
147
- "Q1": "#d62728", # tab red - critical
148
- "Q2": "#ff7f0e", # tab orange - investigate
149
- "Q4": "#1f77b4", # tab blue - low priority
150
- "Q3": "#2ca02c", # tab green - monitor
149
+ "Q1": "#d62728", # top-right quadrant
150
+ "Q2": "#ff7f0e", # top-left quadrant
151
+ "Q4": "#1f77b4", # bottom-right quadrant
152
+ "Q3": "#2ca02c", # bottom-left quadrant
151
153
  }
152
154
  quadrant_display = {
153
- "Q1": "Q1 (Critical)",
154
- "Q2": "Q2 (Investigate)",
155
- "Q3": "Q3 (Monitor)",
156
- "Q4": "Q4 (Low Priority)",
155
+ q: get_quadrant_label(q, x_label="Volume", y_label="Growth")
156
+ for q in ["Q1", "Q2", "Q3", "Q4"]
157
157
  }
158
158
 
159
159
  # plot all points
@@ -316,6 +316,7 @@ def plot_priority_matrix(
316
316
  "quarterly": "Q",
317
317
  "yearly": "Y",
318
318
  "semiannual": "S",
319
+ "monthly": "M",
319
320
  }.get(temporal_granularity, "Q")
320
321
  save_path = f"{plot_dir}/priority_matrix-{entity_name.lower()}-{granularity_suffix}-{timestamp}.png"
321
322
 
@@ -333,6 +334,7 @@ def plot_priority_matrix(
333
334
  "quarterly": "Q",
334
335
  "yearly": "Y",
335
336
  "semiannual": "S",
337
+ "monthly": "M",
336
338
  }.get(temporal_granularity, "Q")
337
339
  csv_path = f"{csv_dir}/priority_matrix-{entity_name.lower()}-{granularity_suffix}-{timestamp}.csv"
338
340
 
@@ -24,7 +24,7 @@ def plot_transition_timeline(
24
24
  max_entities: Optional[int] = 20,
25
25
  figsize: Tuple[int, int] = (16, 12),
26
26
  title: Optional[str] = None,
27
- x_axis_granularity: Literal["quarterly", "semiannual", "yearly"] = "quarterly",
27
+ x_axis_granularity: Literal["quarterly", "semiannual", "yearly", "monthly"] = "quarterly",
28
28
  sort_by_risk_first: bool = True,
29
29
  entity_name: str = "Entity",
30
30
  show_all_periods: bool = False,
@@ -106,7 +106,7 @@ def plot_transition_timeline(
106
106
  risk_order = {"critical": 0, "high": 1, "medium": 2, "low": 3}
107
107
  df["risk_order"] = df["risk_level"].map(risk_order)
108
108
 
109
- # aggregate quarters based on granularity
109
+ # aggregate periods based on granularity
110
110
  if x_axis_granularity == "semiannual":
111
111
 
112
112
  def quarter_to_semester(q_str):
@@ -125,6 +125,9 @@ def plot_transition_timeline(
125
125
  return q_str
126
126
 
127
127
  df["period"] = df["transition_quarter"].apply(quarter_to_year)
128
+ elif x_axis_granularity == "monthly":
129
+ # use transition_quarter as-is (assumed monthly label like YYYY-MM)
130
+ df["period"] = df["transition_quarter"]
128
131
  else: # quarterly
129
132
  df["period"] = df["transition_quarter"]
130
133
 
@@ -267,9 +270,14 @@ def plot_transition_timeline(
267
270
  ax.scatter(x_pos, y_pos, s=100, c=color, alpha=0.8, zorder=2)
268
271
 
269
272
  # add transition label with spike indicator
270
- label = f"{transition['from_quadrant']}→{transition['to_quadrant']}"
273
+ to_quad = str(transition["to_quadrant"])
274
+ label = f"{transition['from_quadrant']}→{to_quad}"
275
+
276
+ # avoid duplicating spike markers when to_quadrant already
277
+ # contains *X / *Y / *XY
278
+ has_inline_spike = "*" in to_quad
271
279
 
272
- if spike_axis:
280
+ if spike_axis and not has_inline_spike:
273
281
  # add superscript indicator
274
282
  if spike_axis == "Y":
275
283
  label += "*$^Y$"
@@ -313,7 +321,9 @@ def plot_transition_timeline(
313
321
  period_labels.append(f"H{semester} {year}")
314
322
  else:
315
323
  period_labels.append(p)
316
- else: # yearly
324
+ elif x_axis_granularity == "yearly":
325
+ period_labels = periods
326
+ else: # monthly
317
327
  period_labels = periods
318
328
 
319
329
  ax.set_xticks(range(len(periods)))
@@ -365,7 +375,12 @@ def plot_transition_timeline(
365
375
  )
366
376
 
367
377
  # set labels
368
- xlabel_map = {"quarterly": "Quarter", "semiannual": "Semester", "yearly": "Year"}
378
+ xlabel_map = {
379
+ "quarterly": "Quarter",
380
+ "semiannual": "Semester",
381
+ "yearly": "Year",
382
+ "monthly": "Month",
383
+ }
369
384
  axis_fontsize = 15
370
385
  ax.set_xlabel(xlabel_map.get(x_axis_granularity, "Period"), fontsize=axis_fontsize)
371
386
  ax.set_ylabel(entity_name, fontsize=axis_fontsize)
@@ -389,6 +404,7 @@ def plot_transition_timeline(
389
404
  "quarterly": "Q",
390
405
  "yearly": "Y",
391
406
  "semiannual": "S",
407
+ "monthly": "M",
392
408
  }.get(temporal_granularity, "Q")
393
409
  plot_path = f"{plot_dir}/transition_timeline-{entity_name.lower()}-{granularity_suffix}-{timestamp}.png"
394
410
  plt.savefig(plot_path, dpi=300, bbox_inches="tight", format="png")
@@ -405,9 +421,20 @@ def plot_transition_timeline(
405
421
  "quarterly": "Q",
406
422
  "yearly": "Y",
407
423
  "semiannual": "S",
424
+ "monthly": "M",
408
425
  }.get(temporal_granularity, "Q")
409
426
  csv_path = f"{csv_dir}/transitions-{entity_name.lower()}-{granularity_suffix}-{timestamp}.csv"
410
- df.to_csv(csv_path, index=False)
427
+
428
+ df_to_save = df
429
+ # For monthly outputs, expose a clearer ``transition_month`` label
430
+ # in the CSV while keeping the internal transition_quarter column
431
+ # for code that relies on it.
432
+ if temporal_granularity == "monthly" and "transition_quarter" in df.columns:
433
+ df_to_save = df.copy().rename(
434
+ columns={"transition_quarter": "transition_month"}
435
+ )
436
+
437
+ df_to_save.to_csv(csv_path, index=False)
411
438
  print(f"Transitions CSV saved: {csv_path}")
412
439
 
413
440
  if close_fig:
@@ -6,6 +6,7 @@ import warnings
6
6
  import matplotlib.pyplot as plt
7
7
  import pandas as pd
8
8
 
9
+ from priorityx.core.quadrants import get_quadrant_label
9
10
  from priorityx.utils.helpers import save_dataframe_to_csv
10
11
 
11
12
  # suppress noisy FancyArrowPatch fallback warnings from annotate/adjustments
@@ -120,16 +121,14 @@ def plot_entity_trajectories(
120
121
 
121
122
  # define colors for quadrants (tab20 - distinct hues)
122
123
  colors = {
123
- "Q1": "#d62728", # critical
124
- "Q2": "#ff7f0e", # investigate
125
- "Q4": "#1f77b4", # low priority
126
- "Q3": "#2ca02c", # monitor
124
+ "Q1": "#d62728", # top-right quadrant
125
+ "Q2": "#ff7f0e", # top-left quadrant
126
+ "Q4": "#1f77b4", # bottom-right quadrant
127
+ "Q3": "#2ca02c", # bottom-left quadrant
127
128
  }
128
129
  quadrant_display = {
129
- "Q1": "Q1 (Critical)",
130
- "Q2": "Q2 (Investigate)",
131
- "Q3": "Q3 (Monitor)",
132
- "Q4": "Q4 (Low Priority)",
130
+ q: get_quadrant_label(q, x_label="Volume", y_label="Growth")
131
+ for q in ["Q1", "Q2", "Q3", "Q4"]
133
132
  }
134
133
 
135
134
  # track usage counts per color to slightly vary hue if needed
@@ -308,15 +307,19 @@ def plot_entity_trajectories(
308
307
  "quarterly": "Q",
309
308
  "yearly": "Y",
310
309
  "semiannual": "S",
310
+ "monthly": "M",
311
311
  }.get(temporal_granularity, "Q")
312
312
  plot_path = f"{plot_dir}/trajectories-{entity_name.lower()}-{granularity_suffix}-{timestamp}.png"
313
313
  plt.savefig(plot_path, dpi=300, bbox_inches="tight", format="png")
314
314
  print(f"Entity trajectory plot saved: {plot_path}")
315
315
 
316
316
  if save_csv:
317
+ # Only save the trajectories actually plotted (df_plot), not the
318
+ # full movement_df. The full movement data remains available in
319
+ # the movement CSVs.
317
320
  target_dir = csv_dir if csv_dir else f"{plot_dir}/../results"
318
321
  csv_path = save_dataframe_to_csv(
319
- movement_df,
322
+ df_plot,
320
323
  artifact="trajectories",
321
324
  entity_name=entity_name,
322
325
  temporal_granularity=temporal_granularity,
@@ -67,6 +67,23 @@ def test_fit_priority_matrix_stats():
67
67
  assert stats["temporal_granularity"] == "quarterly"
68
68
 
69
69
 
70
+ def test_fit_priority_matrix_monthly_basic():
71
+ """Monthly granularity should run and report correct stats flag."""
72
+
73
+ df = generate_test_data()
74
+
75
+ results, stats = fit_priority_matrix(
76
+ df,
77
+ entity_col="entity",
78
+ timestamp_col="date",
79
+ temporal_granularity="monthly",
80
+ min_observations=3,
81
+ )
82
+
83
+ assert len(results) > 0
84
+ assert stats["temporal_granularity"] == "monthly"
85
+
86
+
70
87
  def test_fit_priority_matrix_seed_control():
71
88
  """Verify explicit seeding produces deterministic random effects."""
72
89
 
@@ -2270,7 +2270,7 @@ wheels = [
2270
2270
 
2271
2271
  [[package]]
2272
2272
  name = "priorityx"
2273
- version = "0.2.3"
2273
+ version = "0.2.5"
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
@@ -3,8 +3,6 @@
3
3
 
4
4
  import pandas as pd
5
5
 
6
- RAW_DATA_URL = "https://raw.githubusercontent.com/okkymabruri/priorityx/main/examples/incidents/incidents.csv"
7
-
8
6
  from priorityx.core.glmm import fit_priority_matrix
9
7
  from priorityx.tracking.movement import track_cumulative_movement
10
8
  from priorityx.tracking.transitions import extract_transitions
@@ -21,6 +19,8 @@ from priorityx.utils.helpers import (
21
19
  save_dataframe_to_csv,
22
20
  )
23
21
 
22
+ RAW_DATA_URL = "https://raw.githubusercontent.com/okkymabruri/priorityx/main/examples/incidents/incidents.csv"
23
+
24
24
  # load data from GitHub raw
25
25
  df = pd.read_csv(RAW_DATA_URL, parse_dates=["date"])
26
26
  print("Loaded incidents from GitHub raw URL")
File without changes