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,1290 @@
1
+ """
2
+ ProcessBehavior - Main entry point for process behavior analysis.
3
+
4
+ This module provides a user-friendly interface with IDE auto-completion for column names
5
+ and automatic chart selection driven by the analytical design state (ADS).
6
+
7
+ Usage:
8
+ import processbehavior as pb
9
+
10
+ # Step 1: formulate() - analyze structure and get recommendations
11
+ study = pb.formulate(df, response='Measurement', factors=['Operator'], time='ProductionTime')
12
+
13
+ # Step 2: execute() - run the chart
14
+ result = study.execute()
15
+
16
+ Use the ProcessBehavior class directly when you want the fluent derived-variable verbs,
17
+ which attach before formulating, or `.cols` auto-completion for column references:
18
+
19
+ pbd = ProcessBehavior(df)
20
+ study = pbd.transform('torque', 'log').formulate(response=pbd.cols.torque_log)
21
+
22
+ Note the instance is bound to `pbd`, not `pb`: `pb` is the conventional alias for the
23
+ module itself, and shadowing it makes `pb.formulate(...)` read as the module-level
24
+ function when it is an instance method.
25
+ """
26
+
27
+ from __future__ import annotations
28
+
29
+ import logging
30
+ import warnings
31
+ from dataclasses import dataclass, field
32
+ from typing import TYPE_CHECKING
33
+
34
+ import pandas as pd
35
+
36
+ from .derivations import Derivation
37
+ from .derivations import evaluate as _evaluate_derivation
38
+ from .derivations import validate as _validate_derivation
39
+ from .exceptions import ColumnNotFoundError, ProcessBehaviorWarning, ValidationError
40
+ from .formulation_spec import FormulationSpec
41
+ from .sds_detector import SDSRegistry, SDSResult
42
+
43
+ if TYPE_CHECKING:
44
+ from .study import Study
45
+
46
+ logger = logging.getLogger(__name__)
47
+
48
+ # Threshold for auto-converting object columns to numeric.
49
+ # If >= this fraction of non-NA values convert successfully, apply the conversion.
50
+ _NUMERIC_CONVERSION_THRESHOLD = 0.8
51
+
52
+
53
+ def _is_text_like(series: pd.Series) -> bool:
54
+ """True when a column can hold a garbage *string* token.
55
+
56
+ Enumerates the dtypes to include rather than excluding numeric ones: a categorical or
57
+ a pandas ``string`` column holds strings and must still be scanned, but neither is
58
+ ``object`` and a "not numeric" test would misclassify at least one of them.
59
+ """
60
+ from pandas.api.types import is_object_dtype, is_string_dtype
61
+
62
+ dtype = series.dtype
63
+ if isinstance(dtype, pd.CategoricalDtype):
64
+ return True
65
+ return bool(is_object_dtype(series) or is_string_dtype(series))
66
+
67
+
68
+ def _try_clean_numeric_strings(series: pd.Series) -> pd.Series | None:
69
+ """Try to clean formatted numeric strings (currency, thousands sep, etc.).
70
+
71
+ Returns a numeric Series if cleaning succeeds for >= 80% of non-NA values,
72
+ or None if the column doesn't look numeric after cleaning.
73
+
74
+ Handles: $, EUR, GBP, JPY, unicode currency symbols, thousands commas,
75
+ accounting negatives like (1,234.56), percentage signs, and whitespace.
76
+ """
77
+ from pandas.api.types import is_datetime64_any_dtype, is_numeric_dtype
78
+
79
+ # Don't "clean" already-typed columns. Crucially, datetime64 must be guarded:
80
+ # pd.to_numeric(datetime64) silently succeeds (int64 nanoseconds), which would
81
+ # otherwise replace a date column with huge integers. (Mirrors the datetime
82
+ # guard in DataPreparation._detect_and_convert_type.)
83
+ if is_numeric_dtype(series) or is_datetime64_any_dtype(series) or isinstance(series.dtype, pd.PeriodDtype):
84
+ return None
85
+
86
+ # Only process object columns with actual data
87
+ non_na = series.dropna()
88
+ if len(non_na) == 0:
89
+ return None
90
+
91
+ # Fast path: try direct pd.to_numeric first (handles plain numeric strings)
92
+ direct = pd.to_numeric(series, errors='coerce')
93
+ direct_success = direct.notna().sum()
94
+ original_non_na = non_na.shape[0]
95
+ if direct_success >= original_non_na * _NUMERIC_CONVERSION_THRESHOLD:
96
+ return direct
97
+
98
+ result = _coerce_formatted_numerics(series)
99
+
100
+ # Check success rate against non-NA values only
101
+ converted_count = result.notna().sum()
102
+ if converted_count >= original_non_na * _NUMERIC_CONVERSION_THRESHOLD:
103
+ return result
104
+
105
+ return None
106
+
107
+
108
+ def _coerce_formatted_numerics(series: pd.Series) -> pd.Series:
109
+ """The value transform behind :func:`_try_clean_numeric_strings`, without the
110
+ accept/reject decision.
111
+
112
+ Split out so the decision can be scored against a different population than the one
113
+ transformed — see :func:`_clean_numeric_strings_via_uniques`, which transforms the
114
+ distinct values but must score frequency-weighted over the full column.
115
+ """
116
+ # Convert to string for .str operations (handles mixed float/str columns)
117
+ s = series.astype(str)
118
+ # Preserve original NAs
119
+ original_na_mask = series.isna()
120
+ # Also treat the string 'nan' (from astype) as NA
121
+ str_nan_mask = s == 'nan'
122
+
123
+ # Strip whitespace
124
+ cleaned = s.str.strip()
125
+ # Accounting negatives: (xxx) -> -xxx
126
+ cleaned = cleaned.str.replace(r'^\((.*)\)$', r'-\1', regex=True)
127
+ # Remove currency symbols and optional adjacent whitespace
128
+ # Use literal Unicode chars (€£¥) — raw string \u escapes aren't valid in pyarrow regex
129
+ cleaned = cleaned.str.replace(r'[$€£¥]\s*', '', regex=True)
130
+ # Remove thousands separators (commas)
131
+ cleaned = cleaned.str.replace(',', '', regex=False)
132
+ # Remove percentage signs
133
+ cleaned = cleaned.str.replace('%', '', regex=False)
134
+ # Final strip (catches residual whitespace after symbol removal)
135
+ cleaned = cleaned.str.strip()
136
+
137
+ result = pd.to_numeric(cleaned, errors='coerce')
138
+
139
+ # Restore original NAs (don't count astype('str') artifacts as conversions)
140
+ result[original_na_mask | str_nan_mask] = pd.NA
141
+ return result
142
+
143
+
144
+ # Above this share of distinct values, mapping through the uniques stops paying for
145
+ # itself and the direct path is used instead. Measured at 1M rows: label-like columns
146
+ # win at every cardinality (36x at 10 uniques, still 1.4x when every value is distinct),
147
+ # but numeric-like strings — where the fast to_numeric path already short-circuits —
148
+ # break even around 0.10. 0.05 keeps both kinds on the winning side.
149
+ _UNIQUE_MAP_MAX_RATIO = 0.05
150
+
151
+
152
+ def _clean_numeric_strings_via_uniques(series: pd.Series) -> pd.Series | None:
153
+ """``_try_clean_numeric_strings`` applied to the distinct values, then mapped back.
154
+
155
+ Cleaning is per-value, so on a low-cardinality column — factor labels, coded levels,
156
+ anything categorical — the direct path repeats identical string work millions of
157
+ times. Converting the distinct values and mapping back is 19-36x faster at 1M rows
158
+ with identical output.
159
+
160
+ The threshold is the subtle part. ``_try_clean_numeric_strings`` keeps a conversion
161
+ when >= 80% of *non-NA values* convert, which is frequency-weighted; the same test
162
+ over distinct values is not. A column of 9,900 copies of ``'1'`` plus 100 distinct
163
+ labels converts on the full column (99%) but fails over uniques (1%). So the
164
+ conversion is re-scored here against the full column, never against the uniques.
165
+ """
166
+ from pandas.api.types import is_object_dtype
167
+
168
+ # Plain object only. Series.map preserves an extension dtype — mapping a categorical
169
+ # returns a *categorical* of ints where the direct path returns int64, and a `string`
170
+ # column maps to int64 where the direct path returns nullable Int64. Both are real
171
+ # dtype changes (a categorical is not numeric, which would drop the column out of a
172
+ # client's "numeric columns" picker), and replicating pandas' dtype rules here would
173
+ # be more fragile than simply not taking the shortcut. Object is where the win is.
174
+ if not is_object_dtype(series):
175
+ return _try_clean_numeric_strings(series)
176
+
177
+ non_na = series.dropna()
178
+ if len(non_na) == 0:
179
+ return None
180
+
181
+ uniques = pd.Series(non_na.unique())
182
+ if len(uniques) > len(series) * _UNIQUE_MAP_MAX_RATIO:
183
+ return _try_clean_numeric_strings(series)
184
+
185
+ threshold = len(non_na) * _NUMERIC_CONVERSION_THRESHOLD
186
+
187
+ def _mapped(converted_uniques: pd.Series) -> pd.Series:
188
+ # Keys come from non-NA values only, so no NaN-is-not-equal-to-itself problem.
189
+ # Values absent from the mapping (the original NAs) come back as NaN, matching
190
+ # the direct path.
191
+ return series.map(dict(zip(uniques, converted_uniques, strict=False)))
192
+
193
+ # Fast path, then slow path — the same order and the same acceptance test as
194
+ # _try_clean_numeric_strings, but scored over the full column so the frequency
195
+ # weighting is preserved.
196
+ direct = _mapped(pd.to_numeric(uniques, errors='coerce'))
197
+ if direct.notna().sum() >= threshold:
198
+ return direct
199
+
200
+ formatted = _mapped(_coerce_formatted_numerics(uniques))
201
+ if formatted.notna().sum() >= threshold:
202
+ return formatted
203
+
204
+ return None
205
+
206
+
207
+ @dataclass
208
+ class ColumnRef:
209
+ """
210
+ Column reference with level awareness for IDE discoverability.
211
+
212
+ NOT a str subclass to avoid pandas/numpy quirks, serialization issues,
213
+ and hashing/equality surprises. Implements __hash__ and __eq__ for
214
+ dict key usage. Compares equal to strings for flexibility.
215
+
216
+ Attributes
217
+ ----------
218
+ name : str
219
+ The column name in the DataFrame
220
+ levels : list
221
+ Sorted unique values from the data (property)
222
+ count : int
223
+ Number of distinct levels (property)
224
+
225
+ Examples
226
+ --------
227
+ >>> pb = ProcessBehavior(df)
228
+ >>> pb.cols.Lane # Lane (4): [1, 2, 3, 4]
229
+ >>> pb.cols.Lane.levels # [1, 2, 3, 4]
230
+ >>> pb.cols.Lane.count # 4
231
+
232
+ Use in plan:
233
+
234
+ >>> study = pb.formulate(
235
+ ... response=pb.cols.Weight,
236
+ ... plan={pb.cols.Lane: [1, 2, 3, 4]}
237
+ ... )
238
+ """
239
+
240
+ name: str
241
+ _df: pd.DataFrame = field(repr=False, compare=False)
242
+
243
+ @property
244
+ def levels(self) -> list:
245
+ """Sorted unique values from the data."""
246
+ values = self._df[self.name].dropna().unique()
247
+ try:
248
+ return sorted(values.tolist())
249
+ except TypeError:
250
+ # Mixed types can't be sorted
251
+ return list(values)
252
+
253
+ @property
254
+ def count(self) -> int:
255
+ """Number of distinct levels."""
256
+ return len(self.levels)
257
+
258
+ def __str__(self) -> str:
259
+ return self.name
260
+
261
+ def __hash__(self) -> int:
262
+ return hash(self.name)
263
+
264
+ def __eq__(self, other: object) -> bool:
265
+ if isinstance(other, ColumnRef):
266
+ return self.name == other.name
267
+ if isinstance(other, str):
268
+ return self.name == other
269
+ return False
270
+
271
+ def __repr__(self) -> str:
272
+ lvls = self.levels
273
+ if len(lvls) <= 6:
274
+ return f'{self.name} ({len(lvls)}): {lvls}'
275
+ return f'{self.name} ({len(lvls)}): [{lvls[0]}..{lvls[-1]}]'
276
+
277
+
278
+ class ColumnAccessor:
279
+ """
280
+ Provides IDE auto-completion for DataFrame column names with level awareness.
281
+
282
+ Usage:
283
+ pb = ProcessBehavior(df)
284
+ pb.cols.Height # ColumnRef with auto-completion
285
+ pb.cols.Height.levels # [1.0, 1.5, 2.0, ...]
286
+ pb.cols.Height.count # Number of unique levels
287
+
288
+ This class dynamically creates ColumnRef attributes for each column in the
289
+ DataFrame, enabling IDE auto-completion, preventing typos, and providing
290
+ level discoverability for sampling plans. Columns are sorted alphabetically
291
+ for consistent tab-completion ordering.
292
+ """
293
+
294
+ def __init__(self, df: pd.DataFrame):
295
+ """
296
+ Initialize accessor with DataFrame columns.
297
+
298
+ Args:
299
+ df: The DataFrame whose columns will be accessible
300
+ """
301
+ self._df = df
302
+ # Sort by string representation to handle mixed-type column names
303
+ self._columns = sorted(df.columns, key=str)
304
+ self._attr_to_col = {} # Track sanitized_name → original_column
305
+
306
+ # Dynamically add each column as a ColumnRef attribute
307
+ for col in self._columns:
308
+ # Convert column name to valid Python identifier if needed
309
+ attr_name = self._sanitize_column_name(col)
310
+
311
+ if attr_name in self._attr_to_col:
312
+ # Collision detected - warn and skip
313
+ existing_col = self._attr_to_col[attr_name]
314
+ logger.warning(
315
+ f"Column name collision: '{col}' and '{existing_col}' "
316
+ f"both sanitize to '{attr_name}'. "
317
+ f"'{col}' will only be accessible via pb.cols['{col}']."
318
+ )
319
+ else:
320
+ # Avoid overwriting internal attributes (e.g., _df, _columns)
321
+ if hasattr(self, attr_name):
322
+ logger.warning(
323
+ f"Column '{col}' sanitizes to '{attr_name}' which conflicts "
324
+ f"with an internal attribute. Use pb.cols['{col}'] to access."
325
+ )
326
+ continue
327
+ self._attr_to_col[attr_name] = col
328
+ setattr(self, attr_name, ColumnRef(col, df))
329
+
330
+ def _sanitize_column_name(self, col_name: str) -> str:
331
+ """
332
+ Convert column name to valid Python identifier.
333
+
334
+ Handles spaces, special characters, etc.
335
+
336
+ Args:
337
+ col_name: Original column name
338
+
339
+ Returns:
340
+ Sanitized name safe for use as Python attribute
341
+ """
342
+ # Normalize non-string column names (e.g., integers)
343
+ col_name = str(col_name)
344
+
345
+ # Replace spaces and special chars with underscores
346
+ safe_name = col_name.replace(' ', '_').replace('-', '_')
347
+
348
+ # Remove other special characters
349
+ safe_name = ''.join(c if c.isalnum() or c == '_' else '_' for c in safe_name)
350
+
351
+ # Handle empty names
352
+ if not safe_name:
353
+ return '_empty'
354
+
355
+ # Ensure doesn't start with number
356
+ if safe_name[0].isdigit():
357
+ safe_name = f'col_{safe_name}'
358
+
359
+ return safe_name
360
+
361
+ def __repr__(self) -> str:
362
+ """Display available columns."""
363
+ return f'ColumnAccessor({self._columns})'
364
+
365
+ def __getitem__(self, col_name: str) -> ColumnRef:
366
+ """
367
+ Access column by original name (dict-style).
368
+
369
+ Useful for columns with names that can't be valid Python identifiers
370
+ or when collisions occur during sanitization.
371
+
372
+ Args:
373
+ col_name: Original column name
374
+
375
+ Returns:
376
+ ColumnRef for the column (for use in formulate())
377
+
378
+ Raises:
379
+ ColumnNotFoundError: If column doesn't exist
380
+ """
381
+ resolved_name = col_name
382
+
383
+ # Support string lookup for non-string column labels (e.g., 123 -> "123")
384
+ if resolved_name not in self._df.columns and isinstance(resolved_name, str):
385
+ matching = [c for c in self._df.columns if str(c) == resolved_name]
386
+ if len(matching) == 1:
387
+ resolved_name = matching[0]
388
+
389
+ if resolved_name not in self._df.columns:
390
+ available = list(self._df.columns)
391
+ raise ColumnNotFoundError(
392
+ f"Column '{col_name}' not found. Available: {available}", column=col_name, available=available
393
+ )
394
+ return ColumnRef(resolved_name, self._df)
395
+
396
+ def __dir__(self):
397
+ """Support for tab-completion in IPython/Jupyter."""
398
+ return list(self._attr_to_col.keys())
399
+
400
+
401
+ # ---------------------------------------------------------------------------
402
+ # formulate() helpers (module-level so they're easy to test in isolation)
403
+ # ---------------------------------------------------------------------------
404
+
405
+
406
+ def _validate_factors_or_plan_args(
407
+ factors: list | None, plan: dict | None, time: object | None = None
408
+ ) -> None:
409
+ """Enforce the factors XOR plan contract for formulate().
410
+
411
+ Both provided is rejected (ambiguous source of factor structure).
412
+
413
+ Omitting both is fine as long as there is a ``time`` column: that is a
414
+ single measurement stream over time — the most common SPC study there is,
415
+ and an X/mR chart handles it. It used to be refused outright, while the
416
+ equivalent ``factors=[]`` was accepted and worked, so the two spellings of
417
+ "no factors" did opposite things and the refusal named neither.
418
+
419
+ With no factors, no plan *and* no time there is nothing to analyse
420
+ against: no grid, and no order to plot in.
421
+ """
422
+ if factors is not None and plan is not None:
423
+ raise ValidationError(
424
+ "Cannot specify both 'factors' and 'plan'. Use either:\n"
425
+ ' • factors=[...] to infer structure from observed data (complete designs)\n'
426
+ ' • plan={col: [levels], ...} to specify expected structure (complete + incomplete designs)'
427
+ )
428
+ if factors is None and plan is None and time is None:
429
+ raise ValidationError(
430
+ 'A study needs either a time order or a grouping structure — this has neither.\n\n'
431
+ 'Add whichever describes your data:\n'
432
+ " • time='<column>' — a single measurement stream in order (X/mR charts)\n"
433
+ " • factors=['<column>', ...] — categorical variables defining subgroups\n"
434
+ ' • plan={column: [levels], ...} — expected factor levels, which also\n'
435
+ ' lets incomplete designs be detected\n\n'
436
+ 'A single stream is the common case:\n'
437
+ " study = pb.formulate(df, response='weight', time='hour')"
438
+ )
439
+
440
+
441
+ def _compute_pds(
442
+ detector: SDSRegistry,
443
+ sampling_plan: dict[str, list] | None,
444
+ T_planned: int | None,
445
+ N_planned: int | None,
446
+ ) -> SDSResult | None:
447
+ """Compute the Plan Design State (PDS) when a sampling plan was supplied.
448
+
449
+ PDS is `None` when no plan exists or any of T/N are missing — in that
450
+ case the study has only an ODS (observed) and ADS (analytical) state.
451
+ """
452
+ if sampling_plan is None or T_planned is None or N_planned is None:
453
+ return None
454
+ from math import prod
455
+
456
+ K = prod(len(v) for v in sampling_plan.values())
457
+ pds = detector.classify_from_plan(K, T_planned, N_planned)
458
+ logger.debug('PDS: SDS %s (%s)', pds.sds, pds.reason)
459
+ return pds
460
+
461
+
462
+ class ProcessBehavior:
463
+ """
464
+ Main entry point for process behavior analysis with auto-completion.
465
+
466
+ This class makes analysis frictionless by:
467
+ 1. Providing IDE auto-completion for column names
468
+ 2. Auto-detecting the design-state lineage (PDS / ODS / ADS)
469
+ 3. Showing valid chart types based on the analytical design state (ADS)
470
+ 4. Recommending the best chart for the detected ADS
471
+ 5. Two-step workflow: formulate() then execute()
472
+
473
+ Usage:
474
+ # Basic usage with auto-completion
475
+ pb = ProcessBehavior(df)
476
+
477
+ # Step 1: formulate() - analyze structure and get recommendations
478
+ study = pb.formulate(
479
+ response=pb.cols.Measurement,
480
+ time=pb.cols.Time,
481
+ factors=[pb.cols.Operator, pb.cols.Machine]
482
+ )
483
+
484
+ # Inspect the study
485
+ print(study.observed_design_state.sds) # Detected ODS
486
+ print(study.valid_charts) # What's available
487
+ print(study.recommended_chart) # Best choice
488
+
489
+ # Step 2: execute() - run the chart
490
+ result = study.execute() # Uses recommended chart
491
+ result = study.execute(chart='Xbar') # Or explicit chart
492
+
493
+ Attributes:
494
+ cols: ColumnAccessor for IDE auto-completion of column names
495
+ data: The underlying pandas DataFrame
496
+ """
497
+
498
+ def __init__(self, df: pd.DataFrame, na_values: list[str] | None = None):
499
+ """
500
+ Initialize ProcessBehavior with data and optional NA value handling.
501
+
502
+ Args:
503
+ df: pandas DataFrame containing process data
504
+ na_values: Additional values to treat as NA/missing (beyond pandas defaults).
505
+ Common garbage characters are handled automatically.
506
+ Examples: ['*', '?', '--', 'ND', 'BDL', '<LOD']
507
+
508
+ Examples:
509
+ # Basic usage - automatic garbage character handling
510
+ >>> pb = ProcessBehavior(df)
511
+
512
+ # Custom NA indicators (combined with defaults)
513
+ >>> pb = ProcessBehavior(df, na_values=['-999', '9999', 'MISSING'])
514
+ """
515
+ if not isinstance(df, pd.DataFrame):
516
+ raise ValidationError(f'Expected pandas DataFrame, got {type(df).__name__}')
517
+
518
+ # Default garbage characters commonly found in real-world data
519
+ # These are NOT recognized by pandas by default
520
+ default_na = [
521
+ '*', # Common in lab data for missing/invalid
522
+ '?', # Question mark for unknown
523
+ '--', # Double dash for missing
524
+ 'ND', # Not Detected
525
+ 'BDL', # Below Detection Limit
526
+ 'BQL', # Below Quantification Limit
527
+ '<LOD', # Below Limit of Detection
528
+ '>ULQ', # Above Upper Limit of Quantification
529
+ 'N/D', # Not Detected (variant)
530
+ 'n/d', # Not detected (lowercase)
531
+ 'MISSING',
532
+ 'missing',
533
+ ]
534
+
535
+ # Combine default with user-specified NA values
536
+ all_na_values = default_na + (na_values or [])
537
+
538
+ # Clean the data - replace garbage characters with pd.NA
539
+ cleaned_df = df.copy()
540
+
541
+ # Track which columns had NA values for informative warning
542
+ columns_with_na = []
543
+ na_counts = {}
544
+
545
+ for col in cleaned_df.columns:
546
+ # A garbage token is a string, so only text-like columns can hold one. Scanning
547
+ # numeric/datetime/bool columns is a guaranteed no-op and was ~1.6s of a 3.6s
548
+ # init at 1M x 50. Note the test is "is text-like", not "is not numeric":
549
+ # a CATEGORICAL OF STRINGS can match, and excluding on numeric-ness alone
550
+ # would silently stop cleaning it.
551
+ if not _is_text_like(cleaned_df[col]):
552
+ continue
553
+
554
+ # Count how many garbage values we find
555
+ na_mask = cleaned_df[col].isin(all_na_values)
556
+ na_count = na_mask.sum()
557
+
558
+ if na_count > 0:
559
+ columns_with_na.append(col)
560
+ na_counts[col] = na_count
561
+ # Replace with pd.NA
562
+ cleaned_df.loc[na_mask, col] = pd.NA
563
+
564
+ # Try to convert to numeric if it was originally numeric
565
+ # This handles cases like ['235.5', '*', '237.2'] -> [235.5, NaN, 237.2]
566
+ try:
567
+ # Try conversion - if it fails, keep original dtype
568
+ numeric_col = pd.to_numeric(cleaned_df[col])
569
+ cleaned_df[col] = numeric_col
570
+ except (ValueError, TypeError):
571
+ # Keep as-is if conversion fails (likely string data)
572
+ pass
573
+
574
+ # Tell the user what we changed on their behalf. This is a warning, not
575
+ # a log record: the library configures no logging handler, so a
576
+ # logger.warning here was invisible in most sessions and could not be
577
+ # filtered or escalated by callers.
578
+ if columns_with_na:
579
+ total_na = sum(na_counts.values())
580
+ warnings.warn(
581
+ f'Found {total_na} garbage/NA values across {len(columns_with_na)} column(s):\n'
582
+ + '\n'.join([f' • {col}: {count} values' for col, count in na_counts.items()])
583
+ + '\n\nThese values were converted to NA and will be excluded from analysis.',
584
+ ProcessBehaviorWarning,
585
+ stacklevel=3,
586
+ )
587
+
588
+ # Phase 2: Clean numeric formatting (currency symbols, thousands
589
+ # separators, accounting negatives, percentages) in object columns
590
+ formatting_cleaned = {}
591
+ for col in cleaned_df.columns:
592
+ result = _clean_numeric_strings_via_uniques(cleaned_df[col])
593
+ if result is not None:
594
+ formatting_cleaned[col] = int(result.notna().sum())
595
+ cleaned_df[col] = result
596
+
597
+ if formatting_cleaned:
598
+ warnings.warn(
599
+ f'Cleaned numeric formatting in {len(formatting_cleaned)} column(s):\n'
600
+ + '\n'.join([f' • {col}: {count} values converted' for col, count in formatting_cleaned.items()])
601
+ + '\n\nCurrency symbols, thousands separators, and '
602
+ 'accounting negatives were removed.',
603
+ ProcessBehaviorWarning,
604
+ stacklevel=3,
605
+ )
606
+
607
+ self.data = cleaned_df
608
+ self.cols = ColumnAccessor(self.data)
609
+ # Pending derived-variable specs, accumulated by the fluent verbs.
610
+ # Immutable: each verb returns a new ProcessBehavior with an extended
611
+ # tuple. Materialized into the analytic dataset at formulate().
612
+ self._derivations: tuple[Derivation, ...] = ()
613
+
614
+ logger.info(f'ProcessBehavior: {len(df)} rows, {len(df.columns)} columns')
615
+
616
+ # =========================================================================
617
+ # Factory Methods: Read from files
618
+ # =========================================================================
619
+
620
+ @classmethod
621
+ def read_csv(cls, path: str, na_values: list[str] | None = None, **kwargs) -> ProcessBehavior:
622
+ """
623
+ Read data from a CSV file.
624
+
625
+ Parameters
626
+ ----------
627
+ path : str
628
+ Path to the CSV file.
629
+ na_values : list of str, optional
630
+ Additional values to treat as NA/missing.
631
+ **kwargs
632
+ Additional arguments passed to pandas.read_csv().
633
+
634
+ Returns
635
+ -------
636
+ ProcessBehavior
637
+ ProcessBehavior instance with loaded data.
638
+
639
+ Examples
640
+ --------
641
+ >>> pb = ProcessBehavior.read_csv('fillweight_data.csv')
642
+ >>> pb = ProcessBehavior.read_csv('data.csv', encoding='latin-1')
643
+ """
644
+ df = pd.read_csv(path, **kwargs)
645
+ return cls(df, na_values=na_values)
646
+
647
+ @classmethod
648
+ def read_excel(
649
+ cls, path: str, sheet_name: str | int = 0, na_values: list[str] | None = None, **kwargs
650
+ ) -> ProcessBehavior:
651
+ """
652
+ Read data from an Excel file.
653
+
654
+ Parameters
655
+ ----------
656
+ path : str
657
+ Path to the Excel file (.xlsx, .xls).
658
+ sheet_name : str or int, default 0
659
+ Sheet name or index to read.
660
+ na_values : list of str, optional
661
+ Additional values to treat as NA/missing.
662
+ **kwargs
663
+ Additional arguments passed to pandas.read_excel().
664
+
665
+ Returns
666
+ -------
667
+ ProcessBehavior
668
+ ProcessBehavior instance with loaded data.
669
+
670
+ Examples
671
+ --------
672
+ >>> pb = ProcessBehavior.read_excel('data.xlsx')
673
+ >>> pb = ProcessBehavior.read_excel('data.xlsx', sheet_name='Sheet2')
674
+ """
675
+ df = pd.read_excel(path, sheet_name=sheet_name, **kwargs)
676
+ return cls(df, na_values=na_values)
677
+
678
+ @classmethod
679
+ def read_parquet(cls, path: str, na_values: list[str] | None = None, **kwargs) -> ProcessBehavior:
680
+ """
681
+ Read data from a Parquet file.
682
+
683
+ Parameters
684
+ ----------
685
+ path : str
686
+ Path to the Parquet file.
687
+ na_values : list of str, optional
688
+ Additional values to treat as NA/missing.
689
+ **kwargs
690
+ Additional arguments passed to pandas.read_parquet().
691
+
692
+ Returns
693
+ -------
694
+ ProcessBehavior
695
+ ProcessBehavior instance with loaded data.
696
+
697
+ Examples
698
+ --------
699
+ >>> pb = ProcessBehavior.read_parquet('data.parquet')
700
+ """
701
+ try:
702
+ df = pd.read_parquet(path, **kwargs)
703
+ except ImportError:
704
+ raise ImportError(
705
+ 'Reading Parquet files requires pyarrow or fastparquet. Install with: pip install pyarrow'
706
+ ) from None
707
+ return cls(df, na_values=na_values)
708
+
709
+ @classmethod
710
+ def read_clipboard(cls, na_values: list[str] | None = None, **kwargs) -> ProcessBehavior:
711
+ """
712
+ Read data from the system clipboard.
713
+
714
+ Useful for quickly pasting data from Excel or Google Sheets.
715
+
716
+ Parameters
717
+ ----------
718
+ na_values : list of str, optional
719
+ Additional values to treat as NA/missing.
720
+ **kwargs
721
+ Additional arguments passed to pandas.read_clipboard().
722
+
723
+ Returns
724
+ -------
725
+ ProcessBehavior
726
+ ProcessBehavior instance with clipboard data.
727
+
728
+ Examples
729
+ --------
730
+ Copy data from Excel, then:
731
+
732
+ >>> pb = ProcessBehavior.read_clipboard()
733
+ """
734
+ df = pd.read_clipboard(**kwargs)
735
+ return cls(df, na_values=na_values)
736
+
737
+ @staticmethod
738
+ def _to_column_name(col: str | ColumnRef) -> str:
739
+ """Extract column name from str or ColumnRef."""
740
+ return col.name if isinstance(col, ColumnRef) else col
741
+
742
+ def _validate_plan(self, plan: dict) -> tuple[dict[str, list], list[str], int, int]:
743
+ """
744
+ Validate and normalize sampling plan.
745
+
746
+ Parameters
747
+ ----------
748
+ plan : dict
749
+ Sampling plan with required 'factors' key and optional 'T', 'N'.
750
+ Example: {'factors': {'Lane': [1,2,3,4], 'Phase': [1,2,3]}, 'T': 10, 'N': 2}
751
+
752
+ Returns
753
+ -------
754
+ tuple[dict[str, list], list[str], int | None, int | None]
755
+ (normalized_factors, factor_order, T_planned, N_planned)
756
+
757
+ Raises
758
+ ------
759
+ ValidationError
760
+ If 'factors' key is missing
761
+ ColumnNotFoundError
762
+ If a plan column doesn't exist in the data
763
+ """
764
+ # Require 'factors' key
765
+ if 'factors' not in plan:
766
+ raise ValidationError(
767
+ "Sampling plan must have 'factors' key.\n"
768
+ "Example: plan={'factors': {'Lane': [1,2,3,4], 'Phase': [1,2,3]}, 'T': 10, 'N': 2}"
769
+ )
770
+
771
+ plan_factors = plan['factors']
772
+
773
+ # Require at least one factor
774
+ if not plan_factors:
775
+ raise ValidationError(
776
+ "Sampling plan 'factors' must contain at least one factor.\n"
777
+ "Example: plan={'factors': {'Lane': [1,2,3,4]}, 'T': 10}"
778
+ )
779
+ T_planned = plan.get('T')
780
+ N_planned = plan.get('N')
781
+
782
+ if T_planned is None:
783
+ raise ValidationError(
784
+ "Sampling plan must include 'T' (planned number of time periods).\n"
785
+ "Example: plan={'factors': {'Lane': [1,2,3,4], 'Phase': [1,2,3]}, 'T': 10, 'N': 2}"
786
+ )
787
+ if N_planned is None:
788
+ raise ValidationError(
789
+ "Sampling plan must include 'N' (planned observations per cell).\n"
790
+ "Example: plan={'factors': {'Lane': [1,2,3,4], 'Phase': [1,2,3]}, 'T': 10, 'N': 2}"
791
+ )
792
+
793
+ normalized: dict[str, list] = {}
794
+ factor_order: list[str] = []
795
+
796
+ for col, levels in plan_factors.items():
797
+ col_name = self._to_column_name(col)
798
+
799
+ # Validate column exists
800
+ if col_name not in self.data.columns:
801
+ available = list(self.data.columns)
802
+ raise ColumnNotFoundError(
803
+ f"Plan column '{col_name}' not found in data. Available: {available}",
804
+ column=col_name,
805
+ available=available,
806
+ )
807
+
808
+ # Validate levels is non-empty
809
+ if not levels:
810
+ raise ValidationError(
811
+ f"Factor '{col_name}' has empty level list in plan.\n"
812
+ f'Each factor must have at least one planned level.'
813
+ )
814
+
815
+ normalized[col_name] = list(levels)
816
+ factor_order.append(col_name)
817
+
818
+ # Check for extra observed levels (warn, don't error)
819
+ observed = set(self.data[col_name].dropna().unique())
820
+ planned = set(levels)
821
+ extra = observed - planned
822
+
823
+ if extra:
824
+ extra_list = sorted(extra, key=lambda x: (type(x).__name__, x))
825
+ observed_sorted = sorted(observed, key=lambda x: (type(x).__name__, x))
826
+ logger.warning(
827
+ f"Factor '{col_name}' has observed levels not in plan: {extra_list}\n"
828
+ f' Your plan: {levels}\n'
829
+ f' Observed: {observed_sorted}\n'
830
+ f'\n'
831
+ f' To update your plan:\n'
832
+ f" plan['factors']['{col_name}'] = pb.cols['{col_name}'].levels # Use observed\n"
833
+ f' # or\n'
834
+ f" plan['factors']['{col_name}'] = {observed_sorted} # Add manually"
835
+ )
836
+
837
+ return normalized, factor_order, T_planned, N_planned
838
+
839
+ # =========================================================================
840
+ # Derived variables (transforms + binning) — fluent, immutable verbs
841
+ # =========================================================================
842
+
843
+ @property
844
+ def derivations(self) -> tuple[Derivation, ...]:
845
+ """Pending derived-variable specs attached to this ProcessBehavior."""
846
+ return self._derivations
847
+
848
+ def _with_derivations(self, specs: tuple[Derivation, ...]) -> ProcessBehavior:
849
+ """Return a new ProcessBehavior sharing ``self.data`` with ``specs`` attached."""
850
+ new = ProcessBehavior.__new__(ProcessBehavior)
851
+ new.data = self.data
852
+ new.cols = self.cols
853
+ new._derivations = specs
854
+ return new
855
+
856
+ def _attach(self, spec: Derivation) -> ProcessBehavior:
857
+ """Validate a spec against the current dataset context and attach it.
858
+
859
+ Raises ``ValidationError`` at attach time for column-not-found / non-numeric
860
+ source / output-name collision (the fluent path's commit point).
861
+ """
862
+ existing = {d.output_name for d in self._derivations}
863
+ result = _validate_derivation(spec, self.data, existing_names=existing)
864
+ if not result.ok:
865
+ raise ValidationError(
866
+ f'Cannot add derived variable {spec.output_name!r}: {result.summary()}'
867
+ )
868
+ return self._with_derivations((*self._derivations, spec))
869
+
870
+ def transform(
871
+ self,
872
+ column: str | ColumnRef,
873
+ function: str,
874
+ *,
875
+ label: str | None = None,
876
+ shift: float | None = None,
877
+ exponent: float | None = None,
878
+ on_invalid: str = 'error',
879
+ ) -> ProcessBehavior:
880
+ """Attach a continuous→continuous transform; returns a new ProcessBehavior.
881
+
882
+ ``function`` is one of ``log`` (``ln`` alias), ``log10``, ``sqrt``,
883
+ ``arcsin`` (= ``arcsin(√x)``, x a proportion in ``[0, 1]``), ``inverse``,
884
+ ``square``, ``power`` (needs ``exponent``), ``zscore``. The new column is
885
+ named ``label`` or ``{column}_{function}``. Domain violations are resolved
886
+ at formulate() per ``on_invalid`` (``'error'`` raises with a structured
887
+ count; ``'na'`` sets offenders missing); ``shift`` applies ``f(x + shift)``.
888
+ """
889
+ spec = Derivation.transform(
890
+ self._to_column_name(column), function,
891
+ label=label, shift=shift, exponent=exponent, on_invalid=on_invalid,
892
+ )
893
+ return self._attach(spec)
894
+
895
+ def bin(
896
+ self,
897
+ column: str | ColumnRef,
898
+ *,
899
+ method: str = 'equal_freq',
900
+ n: int = 4,
901
+ breaks: list[float] | None = None,
902
+ bin_labels='range',
903
+ label: str | None = None,
904
+ right: bool = False,
905
+ ) -> ProcessBehavior:
906
+ """Attach a continuous→categorical binning; returns a new ProcessBehavior.
907
+
908
+ ``method`` is ``equal_freq`` (default, n quantile bins), ``equal_width``,
909
+ ``breaks`` (explicit cut points, out-of-range falls into below/above
910
+ categories), or ``sd`` (±1σ, ±2σ about the mean). The new column is an
911
+ ordered categorical named ``label`` or ``{column}_bin``. ``bin_labels`` is
912
+ ``'range'`` (default, from fitted edges), ``'ordinal'``, ``'number'``, or
913
+ an explicit list. Intervals are left-closed ``[a, b)`` unless ``right=True``.
914
+ """
915
+ spec = Derivation.bin(
916
+ self._to_column_name(column),
917
+ method=method, n=n, breaks=breaks, bin_labels=bin_labels, label=label, right=right,
918
+ )
919
+ return self._attach(spec)
920
+
921
+ def add_derived(self, *specs: Derivation) -> ProcessBehavior:
922
+ """Attach one or more pre-built :class:`Derivation` specs (programmatic / app path)."""
923
+ pb = self
924
+ for spec in specs:
925
+ pb = pb._attach(spec)
926
+ return pb
927
+
928
+ def remove_derived(self, id: str) -> ProcessBehavior:
929
+ """Return a new ProcessBehavior with the derivation ``id`` dropped."""
930
+ kept = tuple(d for d in self._derivations if d.id != id)
931
+ if len(kept) == len(self._derivations):
932
+ available = [d.id for d in self._derivations]
933
+ raise ValidationError(f'No derivation with id {id!r}. Attached ids: {available}.')
934
+ return self._with_derivations(kept)
935
+
936
+ def replace_derived(self, id: str, spec: Derivation) -> ProcessBehavior:
937
+ """Return a new ProcessBehavior with derivation ``id`` swapped for ``spec`` (keeps position)."""
938
+ if id not in {d.id for d in self._derivations}:
939
+ available = [d.id for d in self._derivations]
940
+ raise ValidationError(f'No derivation with id {id!r}. Attached ids: {available}.')
941
+ # Validate the replacement against the dataset + the *other* pending names.
942
+ others = {d.output_name for d in self._derivations if d.id != id}
943
+ result = _validate_derivation(spec, self.data, existing_names=others)
944
+ if not result.ok:
945
+ raise ValidationError(
946
+ f'Cannot replace derivation {id!r} with {spec.output_name!r}: {result.summary()}'
947
+ )
948
+ swapped = tuple(spec if d.id == id else d for d in self._derivations)
949
+ return self._with_derivations(swapped)
950
+
951
+ def _materialize_derivations(self) -> tuple[pd.DataFrame, tuple[Derivation, ...]]:
952
+ """Resolve pending specs into columns on a copy of the data (the augmented frame).
953
+
954
+ Evaluates each spec in attach order against the **original** columns
955
+ (chaining is deferred to v2), adds each ``output_name`` column, and returns
956
+ the augmented frame plus the fit-frozen specs. With no pending specs this
957
+ returns ``self.data`` untouched (the no-derivation path is identity).
958
+ ``on_invalid='error'`` with domain violations raises a structured
959
+ ``ValidationError`` here — the only commit-time raise.
960
+ """
961
+ if not self._derivations:
962
+ return self.data, ()
963
+
964
+ frame = self.data.copy()
965
+ resolved: list[Derivation] = []
966
+ for spec in self._derivations:
967
+ res = _evaluate_derivation(spec, self.data[spec.column])
968
+ if (
969
+ spec.family == 'transform'
970
+ and spec.params.get('on_invalid', 'error') == 'error'
971
+ and res.n_invalid > 0
972
+ ):
973
+ sample = list(res.invalid_index[:10])
974
+ raise ValidationError(
975
+ f"Derived variable '{spec.output_name}' ({spec.function} of "
976
+ f"'{spec.column}') has {res.n_invalid} domain violation(s) "
977
+ f'at rows {sample}{"..." if res.n_invalid > 10 else ""}. '
978
+ f"Resolve with shift=, on_invalid='na', or a different transform."
979
+ )
980
+ frame[spec.output_name] = res.values
981
+ resolved.append(spec.with_fitted(res.fitted))
982
+ return frame, tuple(resolved)
983
+
984
+ def formulate(
985
+ self,
986
+ response: str | ColumnRef,
987
+ factors: list[str | ColumnRef] | None = None,
988
+ time: str | ColumnRef | None = None,
989
+ plan: dict | None = None,
990
+ precision: int = 3,
991
+ unit_of_analysis: str | None = None,
992
+ ) -> Study:
993
+ """
994
+ Formulate a study for process behavior analysis.
995
+
996
+ This method prepares and enriches the dataset (including residuals and
997
+ effects as applicable) and returns a Study describing what analyses are
998
+ valid. Call study.execute() to perform chart-specific calculations and
999
+ produce an AnalysisResult.
1000
+
1001
+ Parameters
1002
+ ----------
1003
+ response : str or ColumnRef
1004
+ The response variable (measurement) to analyze.
1005
+ Use pb.cols for IDE auto-completion.
1006
+ factors : list of str or ColumnRef, optional
1007
+ Grouping factors defining rational subgroups (e.g., ['Lane', 'Operator']).
1008
+ If provided, enables Xbar/S analysis. Cannot be used with `plan`.
1009
+ time : str or ColumnRef, optional
1010
+ Time/sequence variable for ordering observations.
1011
+ plan : dict, optional
1012
+ Sampling plan specifying expected factor levels. Must contain a
1013
+ ``'factors'`` key mapping column names (or ColumnRefs) to lists of
1014
+ expected levels. Optionally include ``'T'`` (planned time points)
1015
+ and ``'N'`` (planned observations per cell). Enables ODS 4-6
1016
+ detection by comparing observed structure to planned structure.
1017
+ Cannot be used with ``factors``.
1018
+
1019
+ Example::
1020
+
1021
+ plan={
1022
+ 'factors': {pb.cols.Lane: [1,2,3,4], pb.cols.Phase: [1,2,3]},
1023
+ 'T': 10,
1024
+ 'N': 2
1025
+ }
1026
+ precision : int, default 3
1027
+ Decimal places for output values.
1028
+ unit_of_analysis : str, optional
1029
+ The fundamental entity being measured. For example, in a manufacturing
1030
+ process producing cups filled with yogurt, the unit of analysis is
1031
+ 'filled cup'. In a loan collection process, it would be 'loan contract'.
1032
+ This is informational metadata and does not affect calculations.
1033
+
1034
+ Returns
1035
+ -------
1036
+ Study
1037
+ A Study object with:
1038
+ - PDS / ODS / ADS design-state lineage
1039
+ - Valid and recommended chart types (routed by ADS)
1040
+ - Pre-calculated residuals and effects (via study.dataset)
1041
+ - study.execute() to run chart-specific analysis
1042
+ - study.design() to compare plan vs observed (when plan provided)
1043
+
1044
+ Examples
1045
+ --------
1046
+ Basic formulation (infer factors from data):
1047
+
1048
+ >>> pb = ProcessBehavior(df)
1049
+ >>> study = pb.formulate(response='weight')
1050
+ >>> print(study) # Shows PDS / ODS / ADS, valid charts, next steps
1051
+
1052
+ With factors (complete designs, ADS 1-3):
1053
+
1054
+ >>> study = pb.formulate(
1055
+ ... response='fill_weight',
1056
+ ... factors=['lane', 'phase'],
1057
+ ... time='pull'
1058
+ ... )
1059
+
1060
+ With sampling plan (enables SDS 4-6):
1061
+
1062
+ >>> study = pb.formulate(
1063
+ ... response=pb.cols.Weight,
1064
+ ... time=pb.cols.Pull,
1065
+ ... plan={
1066
+ ... 'factors': {
1067
+ ... pb.cols.Lane: [1, 2, 3, 4],
1068
+ ... pb.cols.Phase: [1, 2, 3] # Even if Phase 3 not in data
1069
+ ... },
1070
+ ... 'T': 10
1071
+ ... }
1072
+ ... )
1073
+ >>> study.design() # Shows planned vs observed structure
1074
+
1075
+ Notes
1076
+ -----
1077
+ **Implicit Time Ordering**
1078
+
1079
+ When no ``time`` parameter is specified, the system treats observation
1080
+ order as implicit time. This design decision is intentional:
1081
+
1082
+ 1. Wheeler's XmR chart fundamentally assumes temporal ordering - moving
1083
+ ranges between consecutive observations only make sense in sequence.
1084
+
1085
+ 2. The ``obs_id`` column (assigned during data preparation) serves as
1086
+ the implicit time dimension, preserving the order in which data was
1087
+ provided.
1088
+
1089
+ If your observations are NOT in temporal order, you MUST specify the
1090
+ ``time`` parameter to ensure correct analysis.
1091
+
1092
+ **Factors are optional; structure is not**
1093
+
1094
+ Supply a grouping structure (``factors`` or ``plan``), a ``time``
1095
+ column, or both. Bishop's design-state classification works over a
1096
+ factor × time grid, and a single measurement stream in time order is
1097
+ the degenerate case of one — charted with X/mR::
1098
+
1099
+ study = pb.formulate(df, response='weight', time='hour')
1100
+
1101
+ Only a study with no factors, no plan *and* no time is refused: there
1102
+ is no grid to classify and no order to plot in.
1103
+
1104
+ **SDS Detection**
1105
+
1106
+ SDS detection runs on raw data (before NA rows are dropped) to preserve
1107
+ attempted-but-invalid cells. This ensures cells with all-NA responses
1108
+ are counted as empty (Nₖₜ=0) rather than vanishing from structure analysis.
1109
+
1110
+ See Also
1111
+ --------
1112
+ Study : The returned Study object
1113
+ ColumnRef : Column reference with .levels property for discoverability
1114
+ """
1115
+ # 1. Validate the factors/plan combination + presence.
1116
+ _validate_factors_or_plan_args(factors, plan, time)
1117
+
1118
+ # 2. Normalize inputs into (response_str, time_str, factors_str,
1119
+ # sampling_plan, factor_order, T_planned, N_planned).
1120
+ response_str = self._to_column_name(response)
1121
+ time_str = self._to_column_name(time) if time is not None else None
1122
+ factors_str, sampling_plan, factor_order, T_planned, N_planned = (
1123
+ self._resolve_factors_and_plan(factors, plan)
1124
+ )
1125
+
1126
+ # 3. Build the FormulationSpec.
1127
+ spec = FormulationSpec(
1128
+ response_var=response_str,
1129
+ rsg_vars=tuple(factors_str) if factors_str else None,
1130
+ time_var=time_str,
1131
+ round_to=precision,
1132
+ unit_of_analysis=unit_of_analysis,
1133
+ )
1134
+
1135
+ # 4. Materialize pending derived variables ONCE into the augmented frame,
1136
+ # before any frame consumer runs. _formulate_from_frame reads this single
1137
+ # frame at all three sites (column validation, ODS detection, ADS) and
1138
+ # never self.data — so a derived factor is seen consistently and the
1139
+ # materialize-once ordering cannot drift in a later refactor.
1140
+ frame, resolved_derivations = self._materialize_derivations()
1141
+ return self._formulate_from_frame(
1142
+ frame,
1143
+ spec,
1144
+ resolved_derivations,
1145
+ sampling_plan=sampling_plan,
1146
+ factor_order=factor_order,
1147
+ T_planned=T_planned,
1148
+ N_planned=N_planned,
1149
+ )
1150
+
1151
+ def _formulate_from_frame(
1152
+ self,
1153
+ frame: pd.DataFrame,
1154
+ spec: FormulationSpec,
1155
+ derivations: tuple[Derivation, ...],
1156
+ *,
1157
+ sampling_plan,
1158
+ factor_order,
1159
+ T_planned,
1160
+ N_planned,
1161
+ ) -> Study:
1162
+ """Run column validation → ODS detection → ADS, all on the single ``frame``.
1163
+
1164
+ Consumes ``frame`` exclusively (never ``self.data``), so the augmented frame
1165
+ flows identically to every site. That is what makes the materialize-once
1166
+ ordering self-enforcing rather than convention: there is no raw frame in
1167
+ scope here to drift to, and no gap between sites to insert one into.
1168
+ """
1169
+ from .analysis_dataset import AnalysisDataSet
1170
+ from .data_preparation import DataPreparation
1171
+ from .study import Study
1172
+
1173
+ # Validate columns (fail fast).
1174
+ DataPreparation().validate_columns(frame, spec)
1175
+
1176
+ # ODS detection on the (augmented) frame — NA response rows preserved so
1177
+ # cells with all-NA responses still count as attempted (Bishop's approach).
1178
+ detector = SDSRegistry()
1179
+ sds_result = detector.detect_sds_from_structure(
1180
+ frame,
1181
+ spec,
1182
+ response_col=spec.response_var,
1183
+ plan=sampling_plan,
1184
+ T_planned=T_planned,
1185
+ )
1186
+
1187
+ # Plan Design State (PDS) — only when a plan was supplied.
1188
+ pds_result = _compute_pds(detector, sampling_plan, T_planned, N_planned)
1189
+
1190
+ # Full AnalysisDataSet (residuals R1-R5, RCR1-RCR5) — ADS post-cleansing.
1191
+ ads = AnalysisDataSet(frame, spec, observed_sds=sds_result.sds)
1192
+
1193
+ # ADS drives analysis: build analysis_plan from ADS, not ODS.
1194
+ analysis_plan = SDSRegistry.get_analysis_plan(
1195
+ ads.analytical_design_state.sds,
1196
+ min_cell_size=ads.analytical_design_state.min_cell_size,
1197
+ )
1198
+
1199
+ return Study(
1200
+ _pdf=self,
1201
+ _spec=spec,
1202
+ _plan=analysis_plan,
1203
+ _ads=ads,
1204
+ _sampling_plan=sampling_plan,
1205
+ _factor_order=factor_order,
1206
+ _T=T_planned,
1207
+ _N=N_planned,
1208
+ _sds_result=sds_result,
1209
+ _pds_result=pds_result,
1210
+ derivations=derivations,
1211
+ )
1212
+
1213
+ def _resolve_factors_and_plan(
1214
+ self,
1215
+ factors: list[str | ColumnRef] | None,
1216
+ plan: dict | None,
1217
+ ) -> tuple[list[str] | None, dict[str, list] | None, list[str] | None, int | None, int | None]:
1218
+ """Normalize factors/plan inputs to the tuple formulate() consumes.
1219
+
1220
+ Exactly one of `factors` or `plan` is non-None (enforced by the
1221
+ caller). Returns `(factors_str, sampling_plan, factor_order,
1222
+ T_planned, N_planned)`.
1223
+
1224
+ - `plan` path: delegates to `_validate_plan` for normalization and
1225
+ extracts T_planned/N_planned.
1226
+ - `factors` path: simple ColumnRef → str normalization; the
1227
+ sampling-plan slots stay None.
1228
+ """
1229
+ if plan is not None:
1230
+ sampling_plan, factor_order, T_planned, N_planned = self._validate_plan(plan)
1231
+ return factor_order, sampling_plan, factor_order, T_planned, N_planned
1232
+ factors_str = [self._to_column_name(f) for f in factors] if factors else None
1233
+ return factors_str, None, None, None, None
1234
+
1235
+ def __repr__(self) -> str:
1236
+ """String representation."""
1237
+ return (
1238
+ f'ProcessBehavior({len(self.data)} rows × {len(self.data.columns)} columns)\n'
1239
+ f'Columns: {list(self.data.columns)}'
1240
+ )
1241
+
1242
+ def __len__(self) -> int:
1243
+ """Return number of rows."""
1244
+ return len(self.data)
1245
+
1246
+
1247
+ def formulate(
1248
+ data,
1249
+ response: str | ColumnRef,
1250
+ factors: list[str | ColumnRef] | None = None,
1251
+ time: str | ColumnRef | None = None,
1252
+ *,
1253
+ plan: dict | None = None,
1254
+ precision: int = 3,
1255
+ unit_of_analysis: str | None = None,
1256
+ ) -> Study:
1257
+ """Formulate a study directly from a DataFrame — the one-call entry point.
1258
+
1259
+ Equivalent to ``ProcessBehavior(data).formulate(...)``, which is what most analyses
1260
+ want: the intermediate object exists to carry derived-variable specs, and a study that
1261
+ declares none has no use for it.
1262
+
1263
+ >>> study = pb.formulate(df, response='weight', factors=['machine'], time='shift')
1264
+ >>> study.execute().plot()
1265
+
1266
+ Reach for :class:`ProcessBehavior` when you need the fluent derivation verbs, since
1267
+ those must be attached before formulating::
1268
+
1269
+ study = ProcessBehavior(df).transform('torque', 'log').formulate(response='torque_log')
1270
+
1271
+ Parameters
1272
+ ----------
1273
+ data : pd.DataFrame
1274
+ Raw data. Garbage tokens are cleaned and numeric-looking columns converted, exactly
1275
+ as ``ProcessBehavior(data)`` does.
1276
+ response, factors, time, plan, precision, unit_of_analysis
1277
+ Passed through to :meth:`ProcessBehavior.formulate`.
1278
+
1279
+ Returns
1280
+ -------
1281
+ Study
1282
+ """
1283
+ return ProcessBehavior(data).formulate(
1284
+ response=response,
1285
+ factors=factors,
1286
+ time=time,
1287
+ plan=plan,
1288
+ precision=precision,
1289
+ unit_of_analysis=unit_of_analysis,
1290
+ )