processbehavior 0.2.0__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.
Files changed (56) hide show
  1. processbehavior/__init__.py +133 -0
  2. processbehavior/analysis.py +2944 -0
  3. processbehavior/analysis_dataset.py +361 -0
  4. processbehavior/analysis_result.py +1276 -0
  5. processbehavior/calibration.py +70 -0
  6. processbehavior/capability.py +720 -0
  7. processbehavior/data_preparation.py +792 -0
  8. processbehavior/datasets/__init__.py +37 -0
  9. processbehavior/datasets/data/coffee_shop_demo_long.csv +385 -0
  10. processbehavior/datasets/loaders.py +66 -0
  11. processbehavior/datasets/synthetic.py +1711 -0
  12. processbehavior/derivations.py +602 -0
  13. processbehavior/effects_calculator.py +603 -0
  14. processbehavior/exceptions.py +216 -0
  15. processbehavior/exporters/__init__.py +5 -0
  16. processbehavior/exporters/excel_exporter.py +708 -0
  17. processbehavior/focused_result.py +329 -0
  18. processbehavior/formulation_spec.py +144 -0
  19. processbehavior/loss_function.py +517 -0
  20. processbehavior/maximum_information.py +285 -0
  21. processbehavior/plotting/__init__.py +46 -0
  22. processbehavior/plotting/_math.py +121 -0
  23. processbehavior/plotting/capability_chart.py +858 -0
  24. processbehavior/plotting/contracts.py +176 -0
  25. processbehavior/plotting/control_chart.py +206 -0
  26. processbehavior/plotting/effects_charts.py +640 -0
  27. processbehavior/plotting/lane_boundaries.py +78 -0
  28. processbehavior/plotting/limits.py +157 -0
  29. processbehavior/plotting/maximum_information_chart.py +395 -0
  30. processbehavior/plotting/plotter.py +1469 -0
  31. processbehavior/plotting/renderers.py +660 -0
  32. processbehavior/plotting/report.py +211 -0
  33. processbehavior/plotting/residuals.py +282 -0
  34. processbehavior/plotting/run_rules_viz.py +150 -0
  35. processbehavior/plotting/stats_box.py +183 -0
  36. processbehavior/plotting/themes.py +544 -0
  37. processbehavior/plotting/x_axis_layout.py +474 -0
  38. processbehavior/plotting/zones.py +127 -0
  39. processbehavior/process_behavior.py +1290 -0
  40. processbehavior/py.typed +0 -0
  41. processbehavior/residual_calculator.py +572 -0
  42. processbehavior/result_signals.py +149 -0
  43. processbehavior/result_tabular.py +296 -0
  44. processbehavior/sds_detector.py +1432 -0
  45. processbehavior/signals/__init__.py +14 -0
  46. processbehavior/signals/config.py +435 -0
  47. processbehavior/signals/detector.py +328 -0
  48. processbehavior/signals/detectors.py +368 -0
  49. processbehavior/signals/result.py +232 -0
  50. processbehavior/spc_constants.py +692 -0
  51. processbehavior/study.py +2733 -0
  52. processbehavior/types.py +154 -0
  53. processbehavior-0.2.0.dist-info/METADATA +279 -0
  54. processbehavior-0.2.0.dist-info/RECORD +56 -0
  55. processbehavior-0.2.0.dist-info/WHEEL +4 -0
  56. processbehavior-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,792 @@
1
+ """
2
+ Data validation and preparation for process behavior analysis.
3
+
4
+ This module handles the initial stages of analysis:
5
+ - Column validation (types, presence)
6
+ - Grouping variable creation
7
+ - Data filtering and sorting
8
+ - Key generation for stable merges
9
+
10
+ Follows the Pythonic Hadley philosophy:
11
+ - Pure functions where possible
12
+ - Fail fast with helpful errors
13
+ - Explicit over implicit
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import logging
19
+ from functools import reduce
20
+ from typing import TYPE_CHECKING
21
+
22
+ import numpy as np
23
+ import pandas as pd
24
+ from natsort import natsorted
25
+ from pandas.api.types import is_numeric_dtype
26
+
27
+ from .exceptions import ColumnNotFoundError, FactorNotFoundError, ValidationError
28
+
29
+ if TYPE_CHECKING:
30
+ from .formulation_spec import FormulationSpec
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ def natural_sort_key(s: str) -> list:
36
+ """
37
+ Key function for natural sorting of strings with embedded numbers.
38
+
39
+ This ensures '1_10' comes after '1_2', not before (lexicographic would put 10 before 2).
40
+
41
+ Parameters
42
+ ----------
43
+ s : str
44
+ String to generate sort key for
45
+
46
+ Returns
47
+ -------
48
+ list
49
+ Sort key that handles embedded numbers correctly
50
+
51
+ Examples
52
+ --------
53
+ >>> sorted(['1_2', '1_10', '1_1'], key=natural_sort_key)
54
+ ['1_1', '1_2', '1_10']
55
+ """
56
+ import re
57
+
58
+ return [int(text) if text.isdigit() else text.lower() for text in re.split(r'(\d+)', s)]
59
+
60
+
61
+ def encode_rsg(factor_values, delimiter: str = '_') -> str:
62
+ """
63
+ Encode factor values into RSG (Rational Subgroup) string.
64
+
65
+ This is the single source of truth for RSG encoding.
66
+ Used by both:
67
+ - DataPreparation._add_composite_column() for observed data
68
+ - Plan expansion for expected data (coverage calculation, missing_combos)
69
+ - analysis_result.py for stratum comparisons
70
+ - plotter.py for chart IDs
71
+
72
+ Handles both tuple/list (multi-factor) and scalar (single-factor) strata.
73
+
74
+ Note: RSG identity assumes canonical factor ordering defined upstream.
75
+ Factor order in encode_rsg() calls must match order in RSG column creation.
76
+ The `by` parameter only affects layout/presentation, not stratum identity.
77
+
78
+ Parameters
79
+ ----------
80
+ factor_values : tuple, list, or scalar
81
+ Values for each factor, in factor order.
82
+ Example: (1, 'A'), [2, 'B'], or just 'A' for single factor
83
+ delimiter : str
84
+ Delimiter between values (default: '_')
85
+
86
+ Returns
87
+ -------
88
+ str
89
+ Encoded RSG string (e.g., "1_A", "2_B", "A")
90
+
91
+ Examples
92
+ --------
93
+ >>> encode_rsg((1, 'A'))
94
+ '1_A'
95
+ >>> encode_rsg([2, 'B'], delimiter='-')
96
+ '2-B'
97
+ >>> encode_rsg((42,))
98
+ '42'
99
+ >>> encode_rsg('Machine_1') # Scalar - returned as-is (not iterated!)
100
+ 'Machine_1'
101
+ >>> encode_rsg(42) # Numeric scalar
102
+ '42'
103
+ """
104
+ if isinstance(factor_values, (tuple, list)):
105
+ return delimiter.join(str(v) for v in factor_values)
106
+ return str(factor_values)
107
+
108
+
109
+ def _column_as_rsg_strings(series: pd.Series) -> pd.Series:
110
+ """One column's values as the strings :func:`encode_rsg` would produce for them.
111
+
112
+ ``astype(str)`` matches ``str(scalar)`` for every dtype we encounter except
113
+ datetime64, where a midnight timestamp renders as ``'2024-01-01'`` rather than
114
+ ``'2024-01-01 00:00:00'``. Datetime factors are rare, so that branch takes the slower
115
+ element-wise path rather than risk a different stratum identity.
116
+ """
117
+ from pandas.api.types import is_datetime64_any_dtype
118
+
119
+ if is_datetime64_any_dtype(series):
120
+ return series.apply(str)
121
+ return series.astype(str)
122
+
123
+
124
+ def encode_rsg_series(df: pd.DataFrame, cols: list[str], delimiter: str = '_') -> pd.Series:
125
+ """Vectorized :func:`encode_rsg` over whole columns — the same encoding, column-wise.
126
+
127
+ Use this instead of ``df[cols].apply(lambda row: encode_rsg(tuple(row)), axis=1)``,
128
+ which materialises every row as a Series (~31x slower at 1M rows) and, worse, silently
129
+ changes the answer: a row Series **upcasts mixed dtypes**, so an int factor beside a
130
+ float factor encodes as ``'1.0'`` instead of ``'1'``. Because plan expansion encodes
131
+ from Python values (``study.py``), that upcast made observed rsg keys unable to match
132
+ expected ones, and ``DesignReport.missing_combos`` / ``extra_combos`` then reported
133
+ every combination as both missing and extra. Converting per column keeps each column
134
+ on its own dtype and matches the plan-side encoding.
135
+ """
136
+ if not cols:
137
+ raise ValidationError('encode_rsg_series requires at least one column.')
138
+
139
+ parts = [_column_as_rsg_strings(df[c]) for c in cols]
140
+ return reduce(lambda left, right: left + delimiter + right, parts)
141
+
142
+
143
+ class DataPreparation:
144
+ """
145
+ Validates and prepares raw data for statistical process control analysis.
146
+
147
+ This class handles the transformation from raw user data to a clean,
148
+ validated dataset ready for analysis. It:
149
+
150
+ 1. Validates column existence and types
151
+ 2. Creates composite grouping variables (e.g., "lane_head")
152
+ 3. Filters out invalid subgroups (n ≤ 1)
153
+ 4. Sorts data appropriately
154
+ 5. Generates stable keys for reproducible analysis
155
+
156
+ The class is designed to be reusable across different analysis types
157
+ and testable in isolation from the heavier analysis logic.
158
+
159
+ Examples
160
+ --------
161
+ Basic usage with grouping:
162
+
163
+ >>> from processbehavior.formulation_spec import FormulationSpec
164
+ >>> spec = FormulationSpec(
165
+ ... response_var='weight',
166
+ ... rsg_vars=('lane', 'head'),
167
+ ... time_var='pull'
168
+ ... )
169
+ >>> prep = DataPreparation()
170
+ >>> clean_df = prep.prepare_dataset(raw_df, spec)
171
+
172
+ The prepared dataset will have:
173
+ - A 'rsg' column: "lane_head"
174
+ - A 'n' column: observations per group
175
+ - Sorted by ['rsg', 'pull']
176
+ - Only groups with n > 1
177
+ """
178
+
179
+ def _get_sort_cols(self, spec: FormulationSpec) -> list[str]:
180
+ """Sort order for prepared data."""
181
+ if spec.has_grouping and spec.has_time:
182
+ return [spec.rsg_var_name, spec.time_var]
183
+ elif spec.has_time:
184
+ return [spec.time_var]
185
+ return []
186
+
187
+ def _get_output_cols(self, spec: FormulationSpec) -> list[str]:
188
+ """Columns to keep after data preparation."""
189
+ cols = [spec.response_var]
190
+ if spec.has_grouping:
191
+ cols = [spec.rsg_var_name, 'n'] + spec.rsg_vars_list + cols
192
+ if spec.has_time and spec.time_var not in cols:
193
+ cols.insert(0, spec.time_var)
194
+ # Preserve the source-row lineage id stamped at the top of prepare_dataset.
195
+ cols.append('obs_id')
196
+ return cols
197
+
198
+ def prepare_dataset(self, df: pd.DataFrame, spec: FormulationSpec) -> pd.DataFrame:
199
+ """
200
+ Prepare raw data for analysis with automatic type conversion.
201
+
202
+ Takes user's raw DataFrame and specification, returns a clean dataset
203
+ ready for statistical analysis. This is a pure transformation - the
204
+ input DataFrame is never modified.
205
+
206
+ **Type Conversion for Correct Sorting:**
207
+ - String-numeric columns ('1', '2', '10') → numeric (1, 2, 10)
208
+ - String-date columns ('2024-01-01') → datetime
209
+ - Numeric, date, datetime, categorical, Period types → unchanged
210
+ - RSG column → categorical with natural sort order
211
+
212
+ This ensures correct sorting for:
213
+ - Time series: 1, 2, 3, 10 (not '1', '10', '2', '3')
214
+ - Factor levels: Lane 1, Lane 2, Lane 10 (not Lane 1, Lane 10, Lane 2)
215
+ - Moving range calculations (adjacent observations)
216
+ - Signal detection rules (consecutive points)
217
+
218
+ Parameters
219
+ ----------
220
+ df : DataFrame
221
+ Raw input data from user
222
+ spec : FormulationSpec
223
+ Configuration specifying analysis parameters
224
+
225
+ Returns
226
+ -------
227
+ DataFrame
228
+ Validated, filtered, sorted dataset with:
229
+ - All required columns present and validated
230
+ - Type conversion applied for correct sorting
231
+ - Composite grouping column if needed (categorical with natural sort)
232
+ - Invalid groups removed (n ≤ 1)
233
+ - Sorted appropriately
234
+ - Only requested output columns
235
+ - No missing values
236
+
237
+ Raises
238
+ ------
239
+ ValueError
240
+ If required columns are missing, wrong type, or all groups invalid
241
+
242
+ Examples
243
+ --------
244
+ >>> spec = FormulationSpec(
245
+ ... response_var='weight',
246
+ ... rsg_vars=('lane',),
247
+ ... time_var='pull'
248
+ ... )
249
+ >>> df = pd.DataFrame({
250
+ ... 'lane': ['A', 'A', 'B'],
251
+ ... 'pull': [1, 2, 1],
252
+ ... 'weight': [10.1, 10.3, 9.9]
253
+ ... })
254
+ >>> prep = DataPreparation()
255
+ >>> result = prep.prepare_dataset(df, spec)
256
+ >>> 'rsg' in result.columns
257
+ True
258
+ >>> result['rsg'].tolist()
259
+ ['A', 'A', 'B']
260
+ """
261
+ logger.debug('Entering prepare_dataset')
262
+ out = df.copy()
263
+ # Stamp a 1-based SOURCE-ROW id (obs_id) as a COLUMN before any row filtering.
264
+ # As a column it survives both cleaning (dropped rows leave gaps in the values)
265
+ # and the groupby/merge in _add_group_sizes (which rebuilds the frame's index).
266
+ # This is the raw→analytic lineage key: a flagged observation traces back to its
267
+ # source-file row (gaps mark rows removed in cleaning).
268
+ out['obs_id'] = np.arange(1, len(out) + 1, dtype=np.int64)
269
+
270
+ # Validate columns early (fail fast!)
271
+ self.validate_columns(out, spec)
272
+
273
+ # Convert types for correct sorting (time_var and factor columns)
274
+ # This handles string-numeric ('1', '10') and string-dates
275
+ if spec.has_time:
276
+ out[spec.time_var], msg = self._detect_and_convert_type(out[spec.time_var], spec.time_var)
277
+
278
+ if spec.has_grouping:
279
+ for col in spec.rsg_vars:
280
+ out[col], msg = self._detect_and_convert_type(out[col], col)
281
+
282
+ # Add composite grouping variable if needed
283
+ if spec.has_grouping:
284
+ out = self._add_grouping_column(out, spec)
285
+
286
+ # Drop rows with missing values in analysis-critical columns BEFORE
287
+ # calculating n. This ensures n reflects actual usable observations,
288
+ # not the raw count which may include rows that will be dropped later.
289
+ drop_cols = [spec.response_var, spec.rsg_var_name]
290
+ if spec.has_time:
291
+ drop_cols.append(spec.time_var)
292
+ out = out.dropna(subset=drop_cols)
293
+
294
+ # FormulationSpec is chart-agnostic — filtering for Xbar/S (n≥2)
295
+ # happens at analysis time, not during data preparation.
296
+ # Just add group sizes without filtering.
297
+ out = self._add_group_sizes(out, spec)
298
+
299
+ # Make RSG categorical with natural sort order
300
+ # This ensures 'Lane_1', 'Lane_2', 'Lane_10' (not 'Lane_1', 'Lane_10', 'Lane_2')
301
+ out[spec.rsg_var_name] = self._make_categorical_rsg(out[spec.rsg_var_name], spec.rsg_var_name)
302
+
303
+ # Sort if required
304
+ sort_cols = self._get_sort_cols(spec)
305
+ if sort_cols:
306
+ out = out.sort_values(sort_cols, kind='stable')
307
+
308
+ # Keep only requested columns
309
+ output_cols = self._get_output_cols(spec)
310
+ out = out[output_cols]
311
+
312
+ # Final safety net: drop any remaining rows with missing data
313
+ # (Primary dropna for grouped data happens earlier, before n calculation)
314
+ out = out.dropna()
315
+
316
+ return out
317
+
318
+ def validate_columns(self, df: pd.DataFrame, spec: FormulationSpec) -> None:
319
+ """
320
+ Validate that required columns exist and have correct types.
321
+
322
+ Checks:
323
+ - All grouping variables exist
324
+ - Time variable exists (if specified)
325
+ - Response variable exists and is numeric
326
+ - No column holds two roles (response/factor/time), and no factor
327
+ is listed twice
328
+
329
+ This function follows "Fail Fast, Fail Helpful" - it raises
330
+ immediately with a message that explains WHAT is wrong, WHY
331
+ it matters, and HOW to fix it.
332
+
333
+ Parameters
334
+ ----------
335
+ df : DataFrame
336
+ Input data to validate
337
+ spec : FormulationSpec
338
+ Specification with column requirements
339
+
340
+ Raises
341
+ ------
342
+ ValueError
343
+ If required columns missing or wrong type, with helpful message
344
+ suggesting fixes
345
+
346
+ Examples
347
+ --------
348
+ >>> spec = FormulationSpec(
349
+ ... response_var='weight',
350
+ ... rsg_vars=('lane',)
351
+ ... )
352
+ >>> df = pd.DataFrame({'weight': [10.1, 10.2]})
353
+ >>> prep = DataPreparation()
354
+ >>> prep.validate_columns(df, spec) # Raises: lane not found
355
+ Traceback (most recent call last):
356
+ ...
357
+ ValueError: One or more grouping variables not found...
358
+ """
359
+ df_cols = df.columns.tolist()
360
+
361
+ # Validate grouping variables
362
+ if spec.has_grouping:
363
+ missing = set(spec.rsg_vars) - set(df_cols)
364
+ if missing:
365
+ raise FactorNotFoundError(
366
+ f'One or more grouping variables not found in dataset.\n'
367
+ f'Missing: {sorted(missing)}\n'
368
+ f'Required: {spec.rsg_vars}\n'
369
+ f'Available columns: {df_cols}\n'
370
+ f'Fix: Check spelling or provide correct column names',
371
+ factor=str(sorted(missing)),
372
+ available=df_cols,
373
+ )
374
+
375
+ # Validate time variable
376
+ if spec.has_time and spec.time_var not in df_cols:
377
+ raise ColumnNotFoundError(
378
+ f"Time variable '{spec.time_var}' not found in dataset.\n"
379
+ f'Available columns: {df_cols}\n'
380
+ f'Fix: Check spelling or specify correct time column',
381
+ column=spec.time_var,
382
+ available=df_cols,
383
+ )
384
+
385
+ # Validate response variable
386
+ if spec.response_var not in df_cols:
387
+ raise ColumnNotFoundError(
388
+ f"Response variable '{spec.response_var}' not found in dataset.\n"
389
+ f'Available columns: {df_cols}\n'
390
+ f'Fix: Check spelling or specify correct measurement column',
391
+ column=spec.response_var,
392
+ available=df_cols,
393
+ )
394
+
395
+ # Validate response variable is numeric
396
+ if not is_numeric_dtype(df[spec.response_var]):
397
+ raise ValidationError(
398
+ f"Response variable '{spec.response_var}' must be numeric.\n"
399
+ f'Current type: {df[spec.response_var].dtype}\n'
400
+ f'Fix: Convert to numeric or choose a different column'
401
+ )
402
+
403
+ # Validate one column, one role. Without this, a column named as both
404
+ # response and factor survives into the analysis frame twice, and the
405
+ # duplicate label surfaces much later as an opaque pandas error while
406
+ # building cell means.
407
+ self._validate_column_roles(spec)
408
+
409
+ @staticmethod
410
+ def _validate_column_roles(spec: FormulationSpec) -> None:
411
+ """Reject a column that holds more than one role, or a repeated factor.
412
+
413
+ Note what is *not* rejected: the time column may also appear in
414
+ ``factors``. That is a supported VAS pattern — declaring time as a
415
+ factor makes each cell a single (factor, time) combination — and
416
+ ``_get_output_cols`` guards the column against being selected twice.
417
+ Only the response has no such guard, and no meaning as a factor.
418
+ """
419
+ factors = spec.rsg_vars_list
420
+
421
+ if spec.response_var in factors:
422
+ raise ValidationError(
423
+ f"Column '{spec.response_var}' is named as both the response and a factor.\n"
424
+ f'A column can hold only one role in a study.\n'
425
+ f'Fix: choose a different response, or drop '
426
+ f"'{spec.response_var}' from factors."
427
+ )
428
+
429
+ if spec.has_time and spec.time_var == spec.response_var:
430
+ raise ValidationError(
431
+ f"Column '{spec.response_var}' is named as both the response and the time variable.\n"
432
+ f'A column can hold only one role in a study.\n'
433
+ f'Fix: choose a different response or a different time column.'
434
+ )
435
+
436
+ duplicates = sorted({f for f in factors if factors.count(f) > 1})
437
+ if duplicates:
438
+ raise ValidationError(
439
+ f'Factor(s) listed more than once: {duplicates}.\n'
440
+ f'Each factor must appear exactly once.\n'
441
+ f'Fix: remove the duplicate entries from factors.'
442
+ )
443
+
444
+ logger.debug('Column validation passed')
445
+
446
+ def build_keys(self, df: pd.DataFrame, spec: FormulationSpec) -> pd.DataFrame:
447
+ """
448
+ Add stable key columns for reproducible analysis.
449
+
450
+ Creates four types of keys:
451
+ - obs_id: Row identity (assigned BEFORE canonical sort, for merges/joins)
452
+ - rsg_key: Tuple key for factor combinations
453
+ - cell_key: Tuple key for (factor × time) cells
454
+ - sort_key: Canonical ordering (assigned AFTER canonical sort)
455
+
456
+ Canonical sort order: (cell_key ascending, obs_id ascending as tie-breaker)
457
+
458
+ Dual-column strategy:
459
+ - **rsg_key (tuple)**: Available for internal operations (fast lookups, hierarchical ops)
460
+ Preserves factor types after type conversion: (1, 1), (1, 2), (1, 10)
461
+ - **rsg (string)**: Used for display (chart labels, user output)
462
+ Created separately in _add_grouping_column() as categorical with natural sort
463
+
464
+ Note: sort_key is study-instance specific and must never be persisted/compared
465
+ across different Studies unless paired with formulation context.
466
+
467
+ Parameters
468
+ ----------
469
+ df : DataFrame
470
+ Input data (must already have grouping/time columns)
471
+ spec : FormulationSpec
472
+ Specification with grouping and time variables
473
+
474
+ Returns
475
+ -------
476
+ DataFrame
477
+ Input data with added key columns, sorted by canonical order
478
+
479
+ Examples
480
+ --------
481
+ >>> df = pd.DataFrame({
482
+ ... 'lane': ['A', 'B'],
483
+ ... 'pull': [1, 1],
484
+ ... 'weight': [10.1, 9.9]
485
+ ... })
486
+ >>> spec = FormulationSpec(
487
+ ... response_var='weight',
488
+ ... rsg_vars=('lane',),
489
+ ... time_var='pull'
490
+ ... )
491
+ >>> prep = DataPreparation()
492
+ >>> result = prep.build_keys(df, spec)
493
+ >>> 'obs_id' in result.columns
494
+ True
495
+ >>> 'sort_key' in result.columns
496
+ True
497
+ """
498
+ out = df.copy()
499
+
500
+ # 1. obs_id — the 1-based SOURCE-ROW id (raw→analytic lineage). Normally stamped
501
+ # in prepare_dataset before cleaning and carried through here; only assign it if a
502
+ # caller invoked build_keys directly without it (kept 1-based for consistency).
503
+ if 'obs_id' not in out.columns:
504
+ out['obs_id'] = np.arange(1, len(out) + 1, dtype=np.int64)
505
+
506
+ k_vars = spec.rsg_vars_list
507
+ t = spec.time_var
508
+
509
+ # 2. Build tuple keys
510
+ # RSG key: tuple of factor values
511
+ if k_vars:
512
+ out['rsg_key'] = list(map(tuple, out[k_vars].astype(object).values))
513
+ else:
514
+ out['rsg_key'] = [()] * len(out)
515
+
516
+ # Cell key: tuple of (factor + time) values
517
+ if k_vars and t:
518
+ out['cell_key'] = list(map(tuple, out[k_vars + [t]].astype(object).values))
519
+ elif t:
520
+ out['cell_key'] = out[t].astype(object).apply(lambda x: (x,))
521
+ else:
522
+ out['cell_key'] = out['rsg_key']
523
+
524
+ # 3. Sort by canonical order: (cell_key, obs_id)
525
+ out = out.sort_values(['cell_key', 'obs_id'], kind='stable')
526
+
527
+ # 4. Assign sort_key AFTER sorting (canonical ordering 0..N-1)
528
+ out['sort_key'] = np.arange(len(out), dtype=np.int64)
529
+
530
+ return out
531
+
532
+ # ========================================================================
533
+ # Private Helper Methods
534
+ # ========================================================================
535
+
536
+ def _add_grouping_column(self, df: pd.DataFrame, spec: FormulationSpec) -> pd.DataFrame:
537
+ """
538
+ Add composite grouping column (e.g., 'lane_head').
539
+
540
+ Creates a user-friendly string column combining multiple factors.
541
+ For single factors, just copies the column with a standard name.
542
+
543
+ Note: This creates the 'rsg' STRING column for user-facing display
544
+ (chart labels, summaries). The 'rsg_key' TUPLE column (created in
545
+ build_keys()) is used for internal computation to ensure correct
546
+ numeric sorting.
547
+ """
548
+ out = df.copy()
549
+
550
+ # Drop 'n' column if it exists (will be recalculated)
551
+ if 'n' in out.columns:
552
+ logger.debug('Dropping existing "n" column (will be recalculated)')
553
+ out = out.drop(columns=['n'])
554
+
555
+ # Create composite column (always as string for display)
556
+ if len(spec.rsg_vars) > 1:
557
+ # Multiple factors: combine with delimiter
558
+ out = self._add_composite_column(
559
+ df=out, cols_to_combine=spec.rsg_vars_list, col_name=spec.rsg_var_name, col_delim=spec.rsg_var_delim
560
+ )
561
+ else:
562
+ # Single factor: copy with standard name.
563
+ out = self._add_column(df=out, new_col_name=spec.rsg_var_name, existing_column=spec.rsg_vars[0])
564
+ src = out[spec.rsg_var_name]
565
+ if isinstance(src.dtype, pd.CategoricalDtype) and src.cat.ordered:
566
+ # Preserve an explicitly-ordered categorical factor's order (e.g. a
567
+ # binned ordinal factor Low < ... < High) through the string rsg
568
+ # column, rather than losing it to a plain str cast + later natsort.
569
+ ordered_cats = [str(c) for c in src.cat.categories]
570
+ out[spec.rsg_var_name] = pd.Categorical(
571
+ src.astype(str), categories=ordered_cats, ordered=True
572
+ )
573
+ else:
574
+ # Ensure RSG is string (even if source column is numeric)
575
+ out[spec.rsg_var_name] = src.astype(str)
576
+
577
+ return out
578
+
579
+ def _add_group_sizes(self, df: pd.DataFrame, spec: FormulationSpec) -> pd.DataFrame:
580
+ """
581
+ Add 'n' column with kt cell sizes without filtering.
582
+
583
+ Groups by kt (factor × time) columns to get true subgroup sizes.
584
+ Used for IMR/R analyses and SDS detection where n=1 per cell is valid.
585
+ """
586
+ # Build kt grouping columns (factor + time if both present)
587
+ kt_cols = [spec.rsg_var_name, spec.time_var] if spec.has_time else [spec.rsg_var_name]
588
+
589
+ grouped = df.groupby(kt_cols, observed=True).size()
590
+ grouped = grouped.reset_index(name='n')
591
+
592
+ out = pd.merge(df, grouped, how='left', on=kt_cols)
593
+ return out
594
+
595
+ def _add_column(self, df: pd.DataFrame, new_col_name: str, existing_column: str) -> pd.DataFrame:
596
+ """Copy an existing column with a new name."""
597
+ if existing_column not in df.columns:
598
+ raise ValueError(
599
+ f"Cannot copy column '{existing_column}' - not in dataset.\nAvailable: {df.columns.tolist()}"
600
+ )
601
+
602
+ out = df.copy()
603
+ out[new_col_name] = df[existing_column]
604
+ return out
605
+
606
+ def _add_composite_column(
607
+ self, df: pd.DataFrame, cols_to_combine: list[str], col_name: str, col_delim: str = '_'
608
+ ) -> pd.DataFrame:
609
+ """
610
+ Create composite column by combining multiple columns.
611
+
612
+ Uses encode_rsg() for consistent encoding with plan expansion.
613
+
614
+ Example: ['lane', 'head'] → 'lane_head' with values like 'A_1', 'B_2'
615
+ """
616
+ missing = set(cols_to_combine) - set(df.columns)
617
+ if missing:
618
+ raise ValueError(
619
+ f'Cannot create composite column - some columns missing.\n'
620
+ f'Missing: {sorted(missing)}\n'
621
+ f'Available: {df.columns.tolist()}'
622
+ )
623
+
624
+ # Validate no missing values in source columns before encoding
625
+ # str(None) → "None" and str(np.nan) → "nan" which won't trigger isna()
626
+ # on the output, so we must check inputs
627
+ if df[cols_to_combine].isna().any().any():
628
+ missing_counts = df[cols_to_combine].isna().sum()
629
+ raise ValidationError(
630
+ f"Cannot build RSG '{col_name}': missing values in factor columns. "
631
+ f'Missing counts: {missing_counts.to_dict()}'
632
+ )
633
+
634
+ if len(cols_to_combine) == 1:
635
+ # Single column - just copy
636
+ return self._add_column(df, col_name, cols_to_combine[0])
637
+
638
+ # Multiple columns - combine with delimiter using shared encode_rsg()
639
+ out = df.copy()
640
+ out[col_name] = encode_rsg_series(df, cols_to_combine, delimiter=col_delim)
641
+
642
+ return out
643
+
644
+ def _detect_and_convert_type(self, series: pd.Series, col_name: str) -> tuple[pd.Series, str | None]:
645
+ """
646
+ Detect and convert string columns to appropriate types.
647
+
648
+ Attempts conversion in this order:
649
+ 1. Try pd.to_numeric() for numeric strings ('1', '2', '10' → 1, 2, 10)
650
+ 2. Try pd.to_datetime() for date strings ('2024-01-01' → datetime)
651
+ 3. Keep original if both fail
652
+
653
+ This ensures correct sorting:
654
+ - Numeric: 1, 2, 10 (not '1', '10', '2')
655
+ - Date/datetime: chronological order
656
+ - Categorical/string: natural sort will be applied
657
+
658
+ Parameters
659
+ ----------
660
+ series : pd.Series
661
+ Column to potentially convert
662
+ col_name : str
663
+ Name of column (for logging)
664
+
665
+ Returns
666
+ -------
667
+ tuple[pd.Series, str | None]
668
+ - Converted series (or original if no conversion)
669
+ - Conversion message (None if no conversion)
670
+
671
+ Examples
672
+ --------
673
+ >>> prep = DataPreparation()
674
+ >>> s = pd.Series(['1', '2', '10'])
675
+ >>> converted, msg = prep._detect_and_convert_type(s, 'time')
676
+ >>> converted.dtype
677
+ dtype('int64')
678
+ >>> '1' in msg # Should mention conversion
679
+ True
680
+ """
681
+ # Skip if already numeric or datetime
682
+ if pd.api.types.is_numeric_dtype(series):
683
+ return series, None
684
+ if pd.api.types.is_datetime64_any_dtype(series):
685
+ return series, None
686
+ if isinstance(series.dtype, pd.PeriodDtype):
687
+ return series, None
688
+
689
+ # Skip if contains Python date/datetime objects (already proper type)
690
+ if len(series) > 0 and isinstance(series.iloc[0], (type(None), type(pd.NaT))):
691
+ # Handle NaT/None
692
+ first_valid_idx = series.first_valid_index()
693
+ if first_valid_idx is not None:
694
+ first_val = series.loc[first_valid_idx]
695
+ else:
696
+ return series, None
697
+ else:
698
+ first_val = series.iloc[0] if len(series) > 0 else None
699
+
700
+ if first_val is not None:
701
+ import datetime
702
+
703
+ if isinstance(first_val, (datetime.date, datetime.datetime)):
704
+ return series, None
705
+
706
+ # Skip if already categorical (assume user set ordering intentionally)
707
+ if isinstance(series.dtype, pd.CategoricalDtype):
708
+ return series, None
709
+
710
+ # Try numeric conversion
711
+ try:
712
+ numeric_vals = pd.to_numeric(series, errors='coerce')
713
+ # Only convert if ALL values succeeded (no NaNs introduced)
714
+ if (not numeric_vals.isna().any() or series.isna().any()) and numeric_vals.notna().any():
715
+ msg = (
716
+ f"Converted column '{col_name}' from string to numeric "
717
+ f"for correct sorting (example: '{series.iloc[0]}' → {numeric_vals.iloc[0]})"
718
+ )
719
+ logger.info(msg)
720
+ return numeric_vals, msg
721
+ except (ValueError, TypeError):
722
+ pass
723
+
724
+ # Try datetime conversion
725
+ try:
726
+ datetime_vals = pd.to_datetime(series, errors='coerce', format='mixed')
727
+ # Only convert if most values succeeded
728
+ success_rate = datetime_vals.notna().sum() / len(series)
729
+ if success_rate > 0.5: # At least 50% converted
730
+ msg = f"Converted column '{col_name}' from string to datetime for correct chronological sorting"
731
+ logger.info(msg)
732
+ return datetime_vals, msg
733
+ except (ValueError, TypeError):
734
+ pass
735
+
736
+ # No conversion possible/needed
737
+ return series, None
738
+
739
+ def _make_categorical_rsg(self, series: pd.Series, col_name: str) -> pd.Series:
740
+ """
741
+ Convert RSG column to categorical with natural sort order.
742
+
743
+ Uses natsort to handle numeric parts in strings correctly:
744
+ - Natural sort: 'Lane_1', 'Lane_2', 'Lane_10'
745
+ - Lexicographic: 'Lane_1', 'Lane_10', 'Lane_2' (wrong!)
746
+
747
+ This ensures groupby, sort_values, and plotting operations
748
+ all respect the correct numeric ordering within strings.
749
+
750
+ Parameters
751
+ ----------
752
+ series : pd.Series
753
+ RSG column (string values like 'Lane_1_Head_10')
754
+ col_name : str
755
+ Name of column (for logging)
756
+
757
+ Returns
758
+ -------
759
+ pd.Series
760
+ Categorical series with natural-sorted categories
761
+
762
+ Examples
763
+ --------
764
+ >>> prep = DataPreparation()
765
+ >>> s = pd.Series(['Lane_1_Head_10', 'Lane_1_Head_2', 'Lane_10_Head_1'])
766
+ >>> cat = prep._make_categorical_rsg(s, 'rsg')
767
+ >>> cat.dtype.ordered
768
+ True
769
+ >>> list(cat.cat.categories)
770
+ ['Lane_1_Head_2', 'Lane_1_Head_10', 'Lane_10_Head_1']
771
+ """
772
+ # Preserve an already-ordered categorical's order (e.g. a binned ordinal
773
+ # factor Low < ... < High); natsort would alphabetize it incorrectly.
774
+ if isinstance(series.dtype, pd.CategoricalDtype) and series.cat.ordered:
775
+ logger.info(
776
+ f"Preserved explicit categorical order for '{col_name}' "
777
+ f'({len(series.cat.categories)} categories)'
778
+ )
779
+ return series
780
+
781
+ # Get unique values and sort naturally
782
+ unique_vals = series.unique()
783
+ sorted_categories = natsorted(unique_vals)
784
+
785
+ # Create ordered categorical
786
+ categorical = pd.Categorical(series, categories=sorted_categories, ordered=True)
787
+
788
+ logger.info(
789
+ f"Created categorical column '{col_name}' with natural sort order ({len(sorted_categories)} categories)"
790
+ )
791
+
792
+ return pd.Series(categorical, index=series.index)