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,2733 @@
1
+ """
2
+ Study class for process behavior analysis formulation.
3
+
4
+ The Study object represents a formulated analysis - it knows the
5
+ design-state lineage of your data (PDS / ODS / ADS), what charts are
6
+ valid, and guides you toward correct analysis.
7
+
8
+ This is the "teaching" layer of the API that helps users understand their data
9
+ before running calculations.
10
+
11
+ Design Philosophy (Pythonic Hadley):
12
+ - Human-first: Rich __repr__ teaches users about their data
13
+ - Pit of success: Valid charts shown, invalid charts explained
14
+ - Composability: study.execute() returns AnalysisResult for chaining
15
+ - Immutable: Frozen dataclass, different formulations create new objects
16
+ """
17
+
18
+ from __future__ import annotations
19
+
20
+ import dataclasses
21
+ import difflib
22
+ import functools
23
+ import math
24
+ import re
25
+ from collections.abc import Mapping
26
+ from dataclasses import dataclass, field
27
+ from typing import TYPE_CHECKING, Literal
28
+
29
+ from .exceptions import ChartNotAvailableError, FactorNotFoundError, ValidationError
30
+ from .residual_calculator import resolve_r6_groupby
31
+ from .sds_detector import SDSRegistry
32
+ from .spc_constants import (
33
+ ALL_RESIDUALS,
34
+ RESIDUAL_ALIASES,
35
+ RESIDUAL_LABELS,
36
+ VALID_BASE_CHARTS,
37
+ normalize_chart_name,
38
+ suggest_chart_name,
39
+ )
40
+
41
+ if TYPE_CHECKING:
42
+ import pandas as pd
43
+
44
+ from .analysis_dataset import AnalysisDataSet
45
+ from .analysis_result import AnalysisResult
46
+ from .calibration import Calibration
47
+ from .capability import CapabilityResult, SpecLimits
48
+ from .derivations import Derivation
49
+ from .formulation_spec import FormulationSpec
50
+ from .loss_function import LossResult
51
+ from .maximum_information import MaximumInformationResult
52
+ from .process_behavior import ProcessBehavior
53
+ from .sds_detector import SDSAnalysisPlan, SDSResult
54
+
55
+
56
+ # Maximum number of combo strings to return in missing_combos/extra_combos
57
+ MAX_COMBO_DISPLAY = 100
58
+
59
+ # The VAS residual codes a user may name in execute(value=...) — stored plus request; see
60
+ # spc_constants for what separates them. Membership only: whether a given code is *available*
61
+ # for this study is a different question (Study.residuals), and whether it can pair with a
62
+ # given chart is a third (Study._residual_pair_problem).
63
+ # Chart names have an equivalent in spc_constants.VALID_BASE_CHARTS, imported above.
64
+ RESIDUAL_CODES = ALL_RESIDUALS
65
+
66
+
67
+ def _base_residual_code(value: str) -> str:
68
+ """'RCR5' -> 'R5', 'r2' -> 'R2'. Recentring does not change which chart a residual pairs with."""
69
+ upper = value.upper()
70
+ return upper[2:] if upper.startswith('RCR') else upper
71
+
72
+
73
+ @dataclass
74
+ class DesignReport:
75
+ """
76
+ Compares sampling plan to observed data.
77
+
78
+ Returned by study.design(). Provides insight into the experimental
79
+ design structure and any mismatches between plan and observation.
80
+
81
+ Attributes
82
+ ----------
83
+ factors_table : pd.DataFrame
84
+ Factor-level summary table with columns: factor, planned, observed,
85
+ missing_levels, extra_levels. (Aliased as ``factors`` for backward
86
+ compatibility.)
87
+ missing_levels : dict[str, list]
88
+ Levels in plan but not observed, per factor
89
+ extra_levels : dict[str, list]
90
+ Levels observed but not in plan, per factor
91
+ K : int
92
+ Planned K (product of factor level counts) or K_observed if no plan
93
+ K_observed : int
94
+ Observed K (nunique of rsg from analysis dataset)
95
+ K_missing : int
96
+ Count of missing RSG combinations
97
+ T : int | None
98
+ Planned T (time points) if specified
99
+ T_observed : int | None
100
+ Observed unique time points
101
+ T_missing : int | None
102
+ T - T_observed if T specified
103
+ R : int | None
104
+ Total cells: K × T (planned) or R_observed if no plan
105
+ R_observed : int | None
106
+ Actual unique (rsg, time) cells in data
107
+ R_missing : int | None
108
+ R - R_observed if R specified
109
+ N : int | None
110
+ Planned N (observations per cell) if specified
111
+ N_observed : tuple[int, float, int] | None
112
+ Observed (min, median, max) cell sizes
113
+ missing_combos : list[str]
114
+ RSG combinations in plan but not observed (capped at 100)
115
+ extra_combos : list[str]
116
+ RSG combinations observed but not in plan (capped at 100)
117
+ extra_count : int
118
+ Total count of extras (use when list is capped)
119
+ sds_reason : str | None
120
+ SDS classification reason from detector
121
+ unit_of_analysis : str | None
122
+ The fundamental entity being measured (if specified)
123
+ structure_summary : str
124
+ Summary of structure discrepancies
125
+
126
+ Examples
127
+ --------
128
+ >>> design = study.design()
129
+ >>> design
130
+ DesignReport(2 factors, with plan)
131
+ SDS reason: incomplete_no_singletons
132
+ K: planned=12, observed=8, missing=4
133
+ T: planned=10, observed=8, missing=2
134
+ R: planned=120, observed=64, missing=56
135
+ N: planned=2, observed=(min=1, median=2.0, max=3)
136
+
137
+ Factors:
138
+ Lane: planned=[1,2,3,4], observed=[1,2,3,4]
139
+ Phase: planned=[1,2,3], observed=[1,2], missing=[3]
140
+
141
+ Structure: Incomplete: 4 RSG groups missing; 2 time points missing
142
+
143
+ >>> design.factors_table
144
+ factor planned observed missing_levels extra_levels
145
+ 0 Lane [1,2,3,4] [1,2,3,4] [] []
146
+ 1 Phase [1,2,3] [1,2] [3] []
147
+
148
+ >>> design.missing_levels
149
+ {'Lane': [], 'Phase': [3]}
150
+
151
+ >>> design.K
152
+ 12
153
+ >>> design.K_observed
154
+ 8
155
+ >>> design.missing_combos
156
+ ['1_3', '2_3', '3_3', '4_3']
157
+ """
158
+
159
+ _sampling_plan: dict[str, list] | None
160
+ _observed_levels: dict[str, list]
161
+ _factors: list[str] # Must be spec.rsg_vars order
162
+ _delim: str = '_' # RSG delimiter (from spec.rsg_var_delim)
163
+ _T: int | None = None # Planned T
164
+ _N: int | None = None # Planned N
165
+ _T_observed: int | None = None # nunique(time) from analysis dataset
166
+ _N_observed: tuple[int, float, int] | None = None # (min, median, max) cell sizes
167
+ _observed_rsg_values: list[str] | None = None # Actual rsg strings from analysis dataset
168
+ _sds_reason: str | None = None # From SDSResult.reason
169
+ _unit_of_analysis: str | None = None # Fundamental entity being measured
170
+ _R_observed: int | None = None # Count of unique (rsg, time) cells
171
+ _min_cell_size: int | None = None # From SDSResult (raw data, pre-filtering)
172
+ _n_empty_cells: int | None = None # From SDSResult (raw data, pre-filtering)
173
+ _pds_result: SDSResult | None = None # Plan Design State
174
+ _ods_result: SDSResult | None = None # Observed Design State
175
+ _ads_result: SDSResult | None = None # Analytical Design State
176
+
177
+ @property
178
+ def factors_table(self) -> pd.DataFrame:
179
+ """
180
+ Factor-level summary table.
181
+
182
+ Returns DataFrame with columns:
183
+ - factor: Factor column name
184
+ - planned: Levels in plan (or observed if no plan)
185
+ - observed: Levels actually in data
186
+ - missing_levels: Levels in plan but not in data
187
+ - extra_levels: Levels in data but not in plan
188
+ """
189
+ import pandas as pd
190
+
191
+ rows = []
192
+ for factor in self._factors:
193
+ observed = self._observed_levels.get(factor, [])
194
+ planned = self._sampling_plan.get(factor, observed) if self._sampling_plan is not None else observed
195
+
196
+ planned_set = set(planned)
197
+ observed_set = set(observed)
198
+
199
+ missing = self._safe_sort(list(planned_set - observed_set))
200
+ extra = self._safe_sort(list(observed_set - planned_set))
201
+
202
+ rows.append(
203
+ {
204
+ 'factor': factor,
205
+ 'planned': ', '.join(str(v) for v in planned),
206
+ 'observed': ', '.join(str(v) for v in observed),
207
+ 'missing_levels': ', '.join(str(v) for v in missing) if missing else '',
208
+ 'extra_levels': ', '.join(str(v) for v in extra) if extra else '',
209
+ }
210
+ )
211
+
212
+ return pd.DataFrame(rows)
213
+
214
+ @property
215
+ def factors(self) -> pd.DataFrame:
216
+ """Backwards-compatible alias for ``factors_table``.
217
+
218
+ Returns the same DataFrame as ``factors_table``. Prefer the new
219
+ name to avoid confusion with ``Study.factors`` (which is a
220
+ ``list[str]`` of column names, not a DataFrame).
221
+ """
222
+ return self.factors_table
223
+
224
+ @property
225
+ def missing_levels(self) -> dict[str, list]:
226
+ """Levels in plan but not observed, per factor."""
227
+ result: dict[str, list] = {}
228
+ for factor in self._factors:
229
+ observed = set(self._observed_levels.get(factor, []))
230
+ planned = set(self._sampling_plan.get(factor, [])) if self._sampling_plan is not None else observed
231
+ result[factor] = self._safe_sort(list(planned - observed))
232
+ return result
233
+
234
+ @property
235
+ def extra_levels(self) -> dict[str, list]:
236
+ """Levels observed but not in plan, per factor."""
237
+ result: dict[str, list] = {}
238
+ for factor in self._factors:
239
+ observed = set(self._observed_levels.get(factor, []))
240
+ planned = set(self._sampling_plan.get(factor, [])) if self._sampling_plan is not None else observed
241
+ result[factor] = self._safe_sort(list(observed - planned))
242
+ return result
243
+
244
+ @property
245
+ def has_plan(self) -> bool:
246
+ """Whether a sampling plan was provided."""
247
+ return self._sampling_plan is not None
248
+
249
+ @staticmethod
250
+ def _safe_sort(items: list) -> list:
251
+ """Sort items safely, handling mixed types."""
252
+ try:
253
+ return sorted(items)
254
+ except TypeError:
255
+ return list(items)
256
+
257
+ @functools.cached_property
258
+ def _expected_rsgs(self) -> set[str]:
259
+ """
260
+ Generate expected RSG strings from sampling plan.
261
+
262
+ Uses encode_rsg() to safely generate strings from factor level tuples,
263
+ avoiding the need to parse RSG strings back (which fails when factor
264
+ values contain the delimiter).
265
+ """
266
+ if not self._sampling_plan:
267
+ return set()
268
+ from itertools import product
269
+
270
+ from processbehavior.data_preparation import encode_rsg
271
+
272
+ factor_levels = [self._sampling_plan[f] for f in self._factors]
273
+ return {encode_rsg(combo, self._delim) for combo in product(*factor_levels)}
274
+
275
+ def _rsg_in_plan(self, rsg: str) -> bool:
276
+ """
277
+ Check if an rsg string matches the sampling plan.
278
+
279
+ Uses set membership against expected RSGs (O(1) lookup).
280
+ This avoids parsing RSG strings, which fails when factor values
281
+ contain the delimiter.
282
+ """
283
+ return rsg in self._expected_rsgs
284
+
285
+ # =========================================================================
286
+ # K, T, N Properties
287
+ # =========================================================================
288
+
289
+ @property
290
+ def K(self) -> int:
291
+ """
292
+ Planned K: product of factor level counts.
293
+
294
+ If no plan is provided, returns K_observed.
295
+ Computed without materializing the cartesian product.
296
+ """
297
+ if not self._sampling_plan:
298
+ return self.K_observed
299
+ from math import prod
300
+
301
+ return prod(len(self._sampling_plan[f]) for f in self._factors)
302
+
303
+ @property
304
+ def K_observed(self) -> int:
305
+ """
306
+ Observed K: actual unique RSG groups in data (nunique).
307
+
308
+ This is the count of unique RSG values in the analysis dataset,
309
+ NOT the product of observed factor levels (which can overstate
310
+ sparse designs).
311
+ """
312
+ return len(self._observed_rsg_values) if self._observed_rsg_values else 0
313
+
314
+ @property
315
+ def K_missing(self) -> int:
316
+ """
317
+ Missing RSG groups: K - (observed combos that are in plan).
318
+
319
+ Computed efficiently without generating the full cartesian product.
320
+ Handles sparse designs where observed may have extra combos not in plan.
321
+ """
322
+ if not self._sampling_plan:
323
+ return 0
324
+ # Count observed rsg values that match the plan
325
+ observed_in_plan = sum(1 for rsg in (self._observed_rsg_values or []) if self._rsg_in_plan(rsg))
326
+ return max(0, self.K - observed_in_plan)
327
+
328
+ @property
329
+ def T(self) -> int | None:
330
+ """Planned time points (if specified in the plan)."""
331
+ return self._T
332
+
333
+ @property
334
+ def T_observed(self) -> int | None:
335
+ """Observed unique time points from analysis dataset."""
336
+ return self._T_observed
337
+
338
+ @property
339
+ def T_missing(self) -> int | None:
340
+ """
341
+ Missing time points: T - T_observed.
342
+
343
+ Returns None if T was not specified in the plan.
344
+ """
345
+ if self._T is None or self._T_observed is None:
346
+ return None
347
+ return max(0, self._T - self._T_observed)
348
+
349
+ @property
350
+ def R(self) -> int | None:
351
+ """
352
+ Total cells in design: K × T.
353
+
354
+ With a plan: Returns K × T (planned total cells).
355
+ Without a plan: Returns R_observed.
356
+ Returns None if no time variable specified.
357
+ """
358
+ if not self._sampling_plan:
359
+ return self.R_observed
360
+ if self._T is None:
361
+ return None
362
+ return self.K * self._T
363
+
364
+ @property
365
+ def R_observed(self) -> int | None:
366
+ """
367
+ Actual unique (rsg, time) cells observed in data.
368
+
369
+ This is the count of filled cells, not K_observed × T_observed
370
+ (which would overstate sparse designs).
371
+ """
372
+ return self._R_observed
373
+
374
+ @property
375
+ def R_missing(self) -> int | None:
376
+ """
377
+ Missing cells: R - R_observed.
378
+
379
+ Returns None if R is not specified (no time variable).
380
+ """
381
+ if self.R is None or self.R_observed is None:
382
+ return None
383
+ return max(0, self.R - self.R_observed)
384
+
385
+ @property
386
+ def N(self) -> int | None:
387
+ """Planned observations per cell (if specified in the plan)."""
388
+ return self._N
389
+
390
+ @property
391
+ def N_observed(self) -> tuple[int, float, int] | None:
392
+ """
393
+ Observed (min, median, max) cell sizes.
394
+
395
+ Median is computed over cell sizes (groupby result), answering
396
+ "typical replication per cell."
397
+ """
398
+ return self._N_observed
399
+
400
+ @property
401
+ def sds_reason(self) -> str | None:
402
+ """
403
+ SDS classification reason from detector.
404
+
405
+ Possible values (per SDSReasonType):
406
+ - 'full_replication': SDS 1 (all N_kt >= 2)
407
+ - 'no_replication': SDS 2 (all N_kt = 1)
408
+ - 'partial_replication': SDS 3 (mixed N_kt)
409
+ - 'incomplete_no_singletons': SDS 4 (empty cells, all observed replicated)
410
+ - 'incomplete_no_replication': SDS 5 (empty cells, all observed n=1)
411
+ - 'incomplete_with_singletons': SDS 6 (empty cells + mixed)
412
+ """
413
+ return self._sds_reason
414
+
415
+ @property
416
+ def sds_reason_detail(self) -> str | None:
417
+ """
418
+ Human-readable SDS classification with explanation.
419
+
420
+ Adds context about why this SDS was assigned based on observed
421
+ data structure. This describes what analysis approaches are valid.
422
+
423
+ Examples:
424
+ 'full_replication (min n >= 2)'
425
+ 'no_replication (all cells n = 1)'
426
+ 'partial_replication (mixed cell sizes)'
427
+ """
428
+ if not self._sds_reason:
429
+ return None
430
+
431
+ if not self._N_observed:
432
+ return self._sds_reason
433
+
434
+ min_n, _, max_n = self._N_observed
435
+
436
+ explanations = {
437
+ 'full_replication': f'min n = {min_n} >= 2',
438
+ 'no_replication': 'all cells n = 1',
439
+ 'partial_replication': f'cell sizes range {min_n} to {max_n}',
440
+ 'incomplete_with_singletons': f'empty cells, mixed replication (min n = {min_n})',
441
+ 'incomplete_no_singletons': f'empty cells, all observed cells replicated (min n = {min_n})',
442
+ 'incomplete_no_replication': 'empty cells, all observed cells n = 1',
443
+ }
444
+
445
+ explanation = explanations.get(self._sds_reason, '')
446
+ if explanation:
447
+ return f'{self._sds_reason} ({explanation})'
448
+ return self._sds_reason
449
+
450
+ @property
451
+ def min_cell_size(self) -> int | None:
452
+ """
453
+ Minimum observations per cell from SDS detection (on raw data).
454
+
455
+ This is the structural gate input that determines SDS classification.
456
+ Note: this may differ from N_observed (which is computed from the
457
+ analysis dataset after NA filtering).
458
+ """
459
+ return self._min_cell_size
460
+
461
+ @property
462
+ def n_empty_cells(self) -> int | None:
463
+ """
464
+ Count of factor x time cells with zero observations (from SDS detection).
465
+
466
+ Cells where all response values are NA or garbage are counted as empty.
467
+ A value > 0 triggers SDS 4-6 classification.
468
+ """
469
+ return self._n_empty_cells
470
+
471
+ @property
472
+ def coverage(self) -> float | None:
473
+ """
474
+ Ratio of observed cells to planned cells (0.0 to 1.0).
475
+
476
+ Only meaningful when a sampling plan is provided and time is specified.
477
+ Returns None if no plan, no time variable, or planned R is zero.
478
+ """
479
+ if not self.has_plan or self._T is None:
480
+ return None
481
+ r = self.R
482
+ r_obs = self.R_observed
483
+ if r is None or r_obs is None or r == 0:
484
+ return None
485
+ return r_obs / r
486
+
487
+ @property
488
+ def remediation(self) -> str | None:
489
+ """
490
+ Actionable guidance for improving the sampling design.
491
+
492
+ Returns a deterministic sentence based on SDS classification
493
+ describing what to change to enable richer analysis.
494
+ Returns None when no remediation is needed (SDS 1 / full replication)
495
+ or when the reason is not recognized.
496
+ """
497
+ if not self._sds_reason:
498
+ return None
499
+
500
+ hints: dict[str, str | None] = {
501
+ 'full_replication': None,
502
+ 'no_replication': (
503
+ 'To enable within-cell variance estimation, collect >= 2 observations per factor x time cell.'
504
+ ),
505
+ 'partial_replication': (
506
+ 'To enable consistent variance estimation, ensure all cells have >= 2 observations.'
507
+ ),
508
+ 'incomplete_with_singletons': (
509
+ 'To complete the design, fill missing factor x time cells and ensure >= 2 observations per cell.'
510
+ ),
511
+ 'incomplete_no_singletons': ('To complete the design, fill missing factor x time cells.'),
512
+ 'incomplete_no_replication': (
513
+ 'To enable Xbar/S charts and within-cell residuals, '
514
+ 'collect >= 2 observations per cell and fill missing cells.'
515
+ ),
516
+ }
517
+ return hints.get(self._sds_reason)
518
+
519
+ @property
520
+ def plan_adherence(self) -> str | None:
521
+ """
522
+ Describes how well data collection matched the sampling plan.
523
+
524
+ This is separate from SDS classification (which determines valid
525
+ analysis approaches). Plan adherence answers: "Did data collection
526
+ go as planned?"
527
+
528
+ Returns None if no plan was provided.
529
+
530
+ Examples:
531
+ 'complete'
532
+ 'underreplicated (min n = 2 < planned N = 4)'
533
+ 'incomplete_time (observed 8 of 10 time points)'
534
+ 'incomplete_factors (observed 5 of 6 factor combinations)'
535
+ """
536
+ if not self._sampling_plan:
537
+ return None
538
+
539
+ issues = []
540
+
541
+ # Check replication adherence
542
+ if self._N is not None and self._N_observed is not None:
543
+ min_n, _, _ = self._N_observed
544
+ if min_n < self._N:
545
+ issues.append(f'underreplicated (min n = {min_n} < planned N = {self._N})')
546
+
547
+ # Check time completeness
548
+ if self._T is not None and self._T_observed is not None and self._T_observed < self._T:
549
+ issues.append(f'incomplete_time (observed {self._T_observed} of {self._T} time points)')
550
+
551
+ # Check factor completeness
552
+ k_observed = self.K_observed
553
+ k_planned = self.K
554
+ if k_observed < k_planned:
555
+ issues.append(f'incomplete_factors (observed {k_observed} of {k_planned} factor combinations)')
556
+
557
+ if not issues:
558
+ return 'complete'
559
+
560
+ return '; '.join(issues)
561
+
562
+ @property
563
+ def unit_of_analysis(self) -> str | None:
564
+ """
565
+ The fundamental entity being measured.
566
+
567
+ For example, 'filled cup' or 'loan contract'. Returns None if not specified.
568
+ """
569
+ return self._unit_of_analysis
570
+
571
+ @property
572
+ def missing_combos(self) -> list[str]:
573
+ """
574
+ RSG combinations in plan but not observed (as rsg strings).
575
+
576
+ Returns at most MAX_COMBO_DISPLAY items to prevent memory issues
577
+ with large K. Use K_missing for the total count.
578
+
579
+ Uses natural sort so '1_10' comes after '1_2'.
580
+ """
581
+ if not self._sampling_plan:
582
+ return []
583
+ from itertools import product
584
+
585
+ from processbehavior.data_preparation import encode_rsg, natural_sort_key
586
+
587
+ # Generate expected combos using _factors order (= spec.rsg_vars)
588
+ factor_levels = [self._sampling_plan[f] for f in self._factors]
589
+ # Use same delimiter as dataset
590
+ expected = {encode_rsg(combo, self._delim) for combo in product(*factor_levels)}
591
+
592
+ # Get observed combos
593
+ observed = set(self._observed_rsg_values) if self._observed_rsg_values else set()
594
+
595
+ # Natural sort (so 1_10 comes after 1_2, not before)
596
+ result = sorted(expected - observed, key=natural_sort_key)
597
+ return result[:MAX_COMBO_DISPLAY]
598
+
599
+ @property
600
+ def extra_count(self) -> int:
601
+ """
602
+ Count of RSG combinations observed but not in plan.
603
+
604
+ Computed efficiently without generating full cartesian product.
605
+ """
606
+ if not self._sampling_plan:
607
+ return 0
608
+ return sum(1 for rsg in (self._observed_rsg_values or []) if not self._rsg_in_plan(rsg))
609
+
610
+ @property
611
+ def extra_combos(self) -> list[str]:
612
+ """
613
+ RSG combinations observed but not in plan (as rsg strings).
614
+
615
+ Returns at most MAX_COMBO_DISPLAY items. Use extra_count for total.
616
+
617
+ Uses natural sort so '1_10' comes after '1_2'.
618
+ """
619
+ if not self._sampling_plan:
620
+ return []
621
+
622
+ from processbehavior.data_preparation import natural_sort_key
623
+
624
+ # Efficient: check each observed value against plan without cartesian product
625
+ extras = [rsg for rsg in (self._observed_rsg_values or []) if not self._rsg_in_plan(rsg)]
626
+
627
+ result = sorted(extras, key=natural_sort_key)
628
+ return result[:MAX_COMBO_DISPLAY]
629
+
630
+ @property
631
+ def structure_summary(self) -> str:
632
+ """
633
+ Summary of structure discrepancy (distinct from SDS reason).
634
+
635
+ Returns 'Complete structure' if plan matches observation,
636
+ otherwise lists what's missing or extra.
637
+ """
638
+ issues = []
639
+ if self.K_missing > 0:
640
+ issues.append(f'{self.K_missing} RSG groups missing')
641
+ if self.extra_count > 0:
642
+ issues.append(f'{self.extra_count} extra RSG groups in data')
643
+ if self.T_missing and self.T_missing > 0:
644
+ issues.append(f'{self.T_missing} time points missing')
645
+ if self._N is not None and self._N_observed:
646
+ min_n, _, _ = self._N_observed
647
+ if min_n < self._N:
648
+ issues.append(f'some cells have n={min_n} < planned n={self._N}')
649
+
650
+ if not issues:
651
+ return 'Complete structure'
652
+ return 'Incomplete: ' + '; '.join(issues)
653
+
654
+ def _repr_metrics_line(self) -> str:
655
+ """Build compact K/T/R/N metrics line for __repr__."""
656
+ parts = []
657
+
658
+ # Min cell size
659
+ if self._min_cell_size is not None and self._min_cell_size > 0:
660
+ parts.append(f'Min cell size: {self._min_cell_size}')
661
+
662
+ # K summary
663
+ if self.has_plan:
664
+ parts.append(f'K: planned={self.K}, observed={self.K_observed}')
665
+ else:
666
+ parts.append(f'K: {self.K_observed}')
667
+
668
+ # T summary
669
+ if self._T is not None:
670
+ parts.append(f'T: planned={self._T}, observed={self._T_observed}')
671
+ elif self._T_observed is not None:
672
+ parts.append(f'T: {self._T_observed}')
673
+
674
+ # R = K × T (total cells)
675
+ if self.R is not None:
676
+ if self.has_plan and self._T is not None:
677
+ parts.append(f'R: planned={self.R}, observed={self.R_observed}')
678
+ elif self.R_observed is not None:
679
+ parts.append(f'R: {self.R_observed}')
680
+
681
+ # N summary
682
+ if self._N is not None and self._N_observed is not None:
683
+ min_n, med_n, max_n = self._N_observed
684
+ parts.append(f'N: planned={self._N}, observed=(min={min_n}, median={med_n}, max={max_n})')
685
+ elif self._N_observed is not None:
686
+ min_n, med_n, max_n = self._N_observed
687
+ parts.append(f'N: (min={min_n}, median={med_n}, max={max_n})')
688
+
689
+ return ' ' + ' | '.join(parts)
690
+
691
+ _REASON_DISPLAY = {
692
+ 'full_replication': 'Full Replication',
693
+ 'no_replication': 'No Replication',
694
+ 'partial_replication': 'Partial Replication',
695
+ 'incomplete_no_singletons': 'Incomplete, No Singletons',
696
+ 'incomplete_no_replication': 'Incomplete, No Replication',
697
+ 'incomplete_with_singletons': 'Incomplete, With Singletons',
698
+ }
699
+
700
+ def _humanize_reason(self, reason: str) -> str:
701
+ """Convert machine reason token to human-readable label."""
702
+ return self._REASON_DISPLAY.get(reason, reason)
703
+
704
+ def __repr__(self) -> str:
705
+ """Nice summary showing plan vs observed per factor with K/T/N."""
706
+ lines = [f'Design Report ({len(self._factors)} factors)']
707
+
708
+ # Unit of analysis (if specified)
709
+ if self._unit_of_analysis:
710
+ lines.append(f' Unit of analysis: {self._unit_of_analysis}')
711
+
712
+ # Design lineage
713
+ ods = self._ods_result
714
+ ads = self._ads_result
715
+ pds = self._pds_result
716
+
717
+ if ods and ads:
718
+ lines.append(' Design-state lineage:')
719
+ if pds is not None:
720
+ lines.append(f' PDS (Planned): {pds.sds} ({self._humanize_reason(pds.reason)})')
721
+ else:
722
+ lines.append(' PDS (Planned): no plan supplied')
723
+ empty_detail = f' — {ods.n_empty_cells} empty cells' if ods.n_empty_cells > 0 else ''
724
+ ods_reason = self._humanize_reason(ods.reason)
725
+ # Label deliberately reads "SDS (Sampling)", not "ODS (Observed)":
726
+ # Bishop calls the state produced by executing the sampling plan the
727
+ # Sampling Design State. The rest of the library still says ODS, and
728
+ # reconciling the two is a pedagogy decision to settle with Tom —
729
+ # tracked in issue #100. Do not "fix" this inconsistency in passing;
730
+ # it is a known, open question, not an oversight.
731
+ lines.append(f' SDS (Sampling): {ods.sds} ({ods_reason}){empty_detail}')
732
+ lines.append(f' ADS (Analytical): {ads.sds} ({self._humanize_reason(ads.reason)})')
733
+ elif self.sds_reason_detail:
734
+ lines.append(f' Classification reason: {self.sds_reason_detail}')
735
+ elif self._sds_reason:
736
+ lines.append(f' Classification reason: {self._sds_reason}')
737
+
738
+ # Plan adherence (only shown when plan is provided)
739
+ if self.plan_adherence:
740
+ lines.append(f' Plan adherence: {self.plan_adherence}')
741
+
742
+ # Compact metrics line (includes min cell size)
743
+ lines.append(self._repr_metrics_line())
744
+
745
+ # Factor details
746
+ lines.append('')
747
+ lines.append(' Factors:')
748
+ for factor in self._factors:
749
+ observed = self._observed_levels.get(factor, [])
750
+ planned = self._sampling_plan.get(factor, observed) if self._sampling_plan is not None else observed
751
+
752
+ planned_set = set(planned)
753
+ observed_set = set(observed)
754
+ missing = self._safe_sort(list(planned_set - observed_set))
755
+
756
+ line = f' {factor}: observed={observed}'
757
+ if self.has_plan:
758
+ line = f' {factor}: planned={planned}, observed={observed}'
759
+ if missing:
760
+ line += f', missing={missing}'
761
+
762
+ lines.append(line)
763
+
764
+ # Structure summary (discrepancy details)
765
+ lines.append('')
766
+ lines.append(f' Structure: {self.structure_summary}')
767
+
768
+ # Available analyses (derived from ADS)
769
+ if self._ads_result and self._ads_result.sds > 0:
770
+ lines.extend(self._repr_available_analyses())
771
+
772
+ return '\n'.join(lines)
773
+
774
+ def _repr_available_analyses(self) -> list[str]:
775
+ """Compact chart×value availability section derived from ADS."""
776
+ ads = self._ads_result
777
+ plan = SDSRegistry.get_analysis_plan(sds=ads.sds, min_cell_size=ads.min_cell_size)
778
+
779
+ lines = ['', f' Available analyses (ADS {ads.sds}):']
780
+
781
+ # Primary charts with recommended marker
782
+ primaries = []
783
+ for chart in plan.valid_charts:
784
+ label = f'{chart} *' if chart == plan.recommended_chart else chart
785
+ primaries.append(label)
786
+ lines.append(f' Primary: {", ".join(primaries)}')
787
+
788
+ # Residual charts grouped by residual
789
+ from collections import defaultdict
790
+
791
+ residual_groups: dict[str, list[str]] = defaultdict(list)
792
+ for chart_type, residual in plan.residual_charts:
793
+ residual_groups[residual].append(chart_type)
794
+
795
+ for residual in ['R2', 'R3', 'R4', 'R5', 'R6']:
796
+ if residual in residual_groups:
797
+ charts = ', '.join(residual_groups[residual])
798
+ lines.append(f' {residual}: {charts}')
799
+
800
+ # Analysis methods
801
+ lines.append(' Methods: Capability, Loss Function, Maximum Information')
802
+
803
+ return lines
804
+
805
+
806
+ class StudyChartAccessor:
807
+ """
808
+ Provides IDE auto-completion for primary chart types in a Study.
809
+
810
+ This class dynamically creates attributes for each valid primary chart type,
811
+ enabling IDE auto-completion and preventing invalid chart selections.
812
+
813
+ Usage:
814
+ study = pb.formulate(response='weight', factors=['lane'])
815
+
816
+ # IDE auto-completes valid primary charts
817
+ result = study.execute(chart=study.charts.Xbar)
818
+
819
+ # Chart residuals using value parameter
820
+ result = study.execute(chart=study.charts.Xbar, value='R5')
821
+
822
+ Attributes are set dynamically based on SDS-specific valid charts.
823
+ """
824
+
825
+ def __init__(self, valid_charts: list[str]):
826
+ """
827
+ Initialize accessor with valid primary chart types.
828
+
829
+ Parameters
830
+ ----------
831
+ valid_charts : list of str
832
+ Primary chart types (Xbar, S, X, mR)
833
+ """
834
+ self._valid_charts = valid_charts
835
+
836
+ # Dynamically add each valid chart as an attribute
837
+ for chart in self._valid_charts:
838
+ # Convert chart names to valid Python identifiers
839
+ attr_name = chart.replace(':', '_').replace('-', '_')
840
+ setattr(self, attr_name, chart)
841
+
842
+ def __repr__(self) -> str:
843
+ """Display available primary chart types."""
844
+ return f'StudyChartAccessor({", ".join(self._valid_charts)})'
845
+
846
+ def __dir__(self) -> list[str]:
847
+ """Support for tab-completion in IPython/Jupyter."""
848
+ return [c.replace(':', '_').replace('-', '_') for c in self._valid_charts]
849
+
850
+
851
+ class StudyResidualAccessor:
852
+ """
853
+ Provides IDE auto-completion for available VAS residuals.
854
+
855
+ Dynamically creates attributes for each residual whose columns
856
+ were actually computed in the analysis dataset.
857
+
858
+ Usage:
859
+ study = pb.formulate(response='y', factors=['lane'], time='t')
860
+
861
+ # IDE auto-completes available residuals
862
+ result = study.execute(chart=study.charts.Xbar, value=study.residuals.R2)
863
+
864
+ # With recentered
865
+ result = study.execute(
866
+ chart=study.charts.X, value=study.residuals.R4, recentered=True
867
+ )
868
+
869
+ Attributes are set dynamically based on which residuals were computed.
870
+ """
871
+
872
+ def __init__(self, available: list[str]):
873
+ self._available = available
874
+ for r in available:
875
+ setattr(self, r, r)
876
+
877
+ def __repr__(self) -> str:
878
+ if not self._available:
879
+ return 'StudyResidualAccessor(none — requires factors + time)'
880
+ return f'StudyResidualAccessor({", ".join(self._available)})'
881
+
882
+ def __dir__(self) -> list[str]:
883
+ return list(self._available)
884
+
885
+ def __iter__(self):
886
+ return iter(self._available)
887
+
888
+ def __len__(self):
889
+ return len(self._available)
890
+
891
+ def __contains__(self, item):
892
+ return item in self._available
893
+
894
+
895
+ @dataclass(frozen=True)
896
+ class Study:
897
+ """
898
+ A Study formulation for process behavior analysis.
899
+
900
+ The Study object represents a complete formulation of how to analyze
901
+ process behavior data. It encapsulates:
902
+
903
+ - The data structure (Sampling Design State)
904
+ - Valid and recommended chart types
905
+ - Available residual analyses
906
+ - Guidance on methodology
907
+ - Pre-calculated dataset with residuals (R1-R5, RCR1-RCR5)
908
+
909
+ This is an immutable object - to change the formulation, create a new Study.
910
+
911
+ Parameters
912
+ ----------
913
+ _pdf : ProcessBehavior
914
+ Reference to the data source
915
+ _spec : FormulationSpec
916
+ Structural configuration (from formulate())
917
+ _plan : SDSAnalysisPlan
918
+ Analysis plan based on detected SDS
919
+ _ads : AnalysisDataSet
920
+ Pre-calculated AnalysisDataSet with rsg, means, and residuals (R1-R5)
921
+
922
+ Examples
923
+ --------
924
+ Create a study formulation:
925
+
926
+ >>> pb = ProcessBehavior(df)
927
+ >>> study = pb.formulate(response='weight', factors=['lane'], time='pull')
928
+ >>> print(study) # Rich display of formulation
929
+
930
+ Check what's available:
931
+
932
+ >>> study.observed_design_state.sds # 1-6
933
+ >>> study.valid_charts # ['Xbar', 'S', ...]
934
+ >>> study.recommended_chart # 'Xbar'
935
+
936
+ Access the prepared dataset:
937
+
938
+ >>> study.dataset # DataFrame with rsg, R1-R5, RCR1-RCR5
939
+
940
+ Run the analysis:
941
+
942
+ >>> result = study.execute() # Uses recommended chart
943
+ >>> result = study.execute(chart='Xbar') # Explicit chart
944
+ >>> result = study.execute(chart=study.charts.Xbar) # Via accessor
945
+
946
+ See Also
947
+ --------
948
+ ProcessBehavior.formulate : Create a Study from data
949
+ AnalysisResult : Result of study.execute()
950
+ """
951
+
952
+ _pdf: ProcessBehavior
953
+ _spec: FormulationSpec
954
+ _plan: SDSAnalysisPlan
955
+ _ads: AnalysisDataSet
956
+ _sampling_plan: dict[str, list] | None = None
957
+ _factor_order: list[str] | None = None
958
+ _T: int | None = None # Planned time points
959
+ _N: int | None = None # Planned observations per cell
960
+ _sds_result: SDSResult | None = None # ODS result for accessing .reason in DesignReport
961
+ _pds_result: SDSResult | None = None # PDS result (None when no plan)
962
+ calibrations: Mapping[str, Calibration] = field(default_factory=dict) # label -> Calibration
963
+ # Resolved (fit-frozen) derived-variable specs materialized into this study's
964
+ # analytic dataset. Empty when no derived variables were attached. Provenance
965
+ # + serialization target; the frozen `fitted` values make binning reproducible.
966
+ derivations: tuple[Derivation, ...] = ()
967
+
968
+ # =========================================================================
969
+ # User-Facing Properties (Clean Names)
970
+ # =========================================================================
971
+
972
+ @property
973
+ def response(self) -> str:
974
+ """
975
+ The response variable being analyzed.
976
+
977
+ This is the measurement or outcome variable that will be charted.
978
+ """
979
+ return self._spec.response_var
980
+
981
+ @property
982
+ def factors(self) -> list[str] | None:
983
+ """
984
+ Grouping factors defining rational subgroups.
985
+
986
+ These are the categorical variables (like Lane, Operator, Machine)
987
+ that define how observations are grouped. Returns None if no
988
+ factors are specified.
989
+ """
990
+ rsg_vars = self._spec.rsg_vars
991
+ return list(rsg_vars) if rsg_vars else None
992
+
993
+ @property
994
+ def time(self) -> str | None:
995
+ """
996
+ Time variable for ordering observations.
997
+
998
+ This defines the sequence of measurements (like Pull, Day, Hour).
999
+ Returns None if no time variable is specified.
1000
+ """
1001
+ return self._spec.time_var
1002
+
1003
+ @property
1004
+ def precision(self) -> int:
1005
+ """
1006
+ Decimal precision for output values.
1007
+
1008
+ Statistics and chart values will be rounded to this many decimal places.
1009
+ """
1010
+ return self._spec.round_to
1011
+
1012
+ @property
1013
+ def unit_of_analysis(self) -> str | None:
1014
+ """
1015
+ The fundamental entity being measured.
1016
+
1017
+ For example, in a manufacturing process producing cups filled with yogurt,
1018
+ the unit of analysis is 'filled cup'. In a loan collection process, it
1019
+ would be 'loan contract'. Returns None if not specified.
1020
+ """
1021
+ return self._spec.unit_of_analysis
1022
+
1023
+ @property
1024
+ def dataset(self) -> pd.DataFrame:
1025
+ """
1026
+ Full analysis dataset with means and residuals.
1027
+
1028
+ This is the pre-calculated dataset produced during formulate().
1029
+ It includes:
1030
+ - rsg: Rational subgroup identifier
1031
+ - Ybar, Ybar_k, Ybar_t, Ybar_kt: Hierarchical means
1032
+ - R1-R5: VAS residuals (where applicable for the SDS)
1033
+ - RCR1-RCR5: Re-centered residuals (Y reconstructed from components)
1034
+
1035
+ Returns a copy to preserve immutability. The frame itself is immutable
1036
+ after formulate() — execute() never adds columns to it; request
1037
+ residuals (R6/RCR6) live only on the result that asked for them.
1038
+
1039
+ Returns
1040
+ -------
1041
+ pd.DataFrame
1042
+ Copy of the analysis dataset
1043
+
1044
+ Examples
1045
+ --------
1046
+ >>> study = pdf.formulate(response='weight', factors=['lane'], time='pull')
1047
+ >>> df = study.dataset
1048
+ >>> df[['rsg', 'weight', 'R1', 'R2', 'R3', 'R4', 'R5']].head()
1049
+ """
1050
+ return self._ads.analysis_dataset.copy()
1051
+
1052
+ # =========================================================================
1053
+ # Design State Properties (PDS / ODS / ADS)
1054
+ # =========================================================================
1055
+
1056
+ @property
1057
+ def plan_design_state(self) -> SDSResult | None:
1058
+ """
1059
+ Plan Design State (PDS) — what the user intended to collect.
1060
+
1061
+ Computed from plan parameters (K×T×N). Always SDS 1 (N>=2) or
1062
+ SDS 2 (N==1). Returns None when no plan is provided.
1063
+
1064
+ Returns
1065
+ -------
1066
+ SDSResult or None
1067
+ """
1068
+ return self._pds_result
1069
+
1070
+ @property
1071
+ def observed_design_state(self) -> SDSResult:
1072
+ """
1073
+ Observed Design State (ODS) — what was actually collected.
1074
+
1075
+ Detected on raw data (before NA filtering). Diagnostic/lineage only;
1076
+ the ADS drives analysis decisions.
1077
+
1078
+ Returns
1079
+ -------
1080
+ SDSResult
1081
+ """
1082
+ if self._sds_result is None:
1083
+ raise RuntimeError(
1084
+ 'Study has no observed_design_state — it was not built via '
1085
+ 'ProcessBehavior.formulate(). Use pb.formulate(...) to '
1086
+ 'construct Study; direct construction is not supported.'
1087
+ )
1088
+ return self._sds_result
1089
+
1090
+ @property
1091
+ def analytical_design_state(self) -> SDSResult:
1092
+ """
1093
+ Analytical Design State (ADS) — what is fit for analysis.
1094
+
1095
+ Computed on tidy data (after NA filtering). Drives valid charts,
1096
+ residual availability, R2 method, and interaction method selection.
1097
+
1098
+ Returns
1099
+ -------
1100
+ SDSResult
1101
+ """
1102
+ return self._ads.analytical_design_state
1103
+
1104
+ @property
1105
+ def ads_reason(self) -> str | None:
1106
+ """
1107
+ Machine-readable reason token for the analytical design state.
1108
+
1109
+ Returns SDSReasonType value, e.g. 'full_replication', 'no_replication'.
1110
+ Returns None when ADS=0 (no valid observations after data cleaning).
1111
+ """
1112
+ return self.analytical_design_state.reason
1113
+
1114
+ @property
1115
+ def ads_description(self) -> str:
1116
+ """
1117
+ Human-readable description of the analytical design state.
1118
+
1119
+ Returns prose from SDSRegistry, e.g.
1120
+ 'Full replication (all cells n>=2)'.
1121
+
1122
+ Reflects the analyzable structure after data cleaning.
1123
+ """
1124
+ chars = SDSRegistry().get_sds_characteristics(self.analytical_design_state.sds)
1125
+ return chars['description']
1126
+
1127
+ # =========================================================================
1128
+ # Chart Properties
1129
+ # =========================================================================
1130
+
1131
+ @property
1132
+ def valid_charts(self) -> list[str]:
1133
+ """
1134
+ Chart types that are valid for this data structure.
1135
+
1136
+ This is *chart-family* validity, decided by the Analytical Design
1137
+ State: whether the chart has any legitimate use on this study. It is
1138
+ not the same question as "will ``execute(chart=...)`` succeed".
1139
+
1140
+ On ADS 2, for instance, ``'Xbar'`` is listed — an Xbar of a residual
1141
+ (``value='R6'``) or of a pooled subgroup (``by=[factor]``) computes
1142
+ fine — while charting the *response* has nothing to subgroup and
1143
+ raises. For that narrower question use :attr:`support` (which carries
1144
+ a ``reason``) or :meth:`why_not`.
1145
+
1146
+ Returns
1147
+ -------
1148
+ list of str
1149
+ Valid chart types (e.g., ['Histogram', 'Xbar', 'S', 'X', 'mR']).
1150
+ Empty list when ADS=0 (no valid observations after cleaning).
1151
+
1152
+ See Also
1153
+ --------
1154
+ support : Availability matrix including data-aware reasons.
1155
+ why_not : Explains a specific refusal.
1156
+ """
1157
+ if self.analytical_design_state.sds == 0:
1158
+ return []
1159
+ return self._plan.valid_charts
1160
+
1161
+ @property
1162
+ def recommended_chart(self) -> str | None:
1163
+ """
1164
+ The recommended chart type for this data structure.
1165
+
1166
+ This is the chart type that best suits your data based on
1167
+ Bishop methodology. Returns None when ADS=0
1168
+ (no valid observations after data cleaning).
1169
+ """
1170
+ if self.analytical_design_state.sds == 0:
1171
+ return None
1172
+ return self._plan.recommended_chart
1173
+
1174
+ @property
1175
+ def residual_charts(self) -> list[tuple[str, str]]:
1176
+ """
1177
+ Available residual chart types for VAS analysis.
1178
+
1179
+ Returns (chart_type, residual) tuples matching the ``execute()``
1180
+ signature: ``study.execute(chart=chart_type, value=residual)``.
1181
+
1182
+ Returns
1183
+ -------
1184
+ list of tuple[str, str]
1185
+ Available residual charts, e.g. ``[('S', 'R2'), ('Xbar', 'R5')]``
1186
+
1187
+ Examples
1188
+ --------
1189
+ >>> for chart, value in study.residual_charts:
1190
+ ... result = study.execute(chart=chart, value=value)
1191
+ """
1192
+ ads_cols = set(self._ads.analysis_dataset.columns)
1193
+ # R6 is computed on-the-fly from R5+R2, so check prerequisites instead of column
1194
+ r6_available = {'R5', 'R2'}.issubset(ads_cols)
1195
+ return [
1196
+ (chart, value)
1197
+ for chart, value in self._plan.residual_charts
1198
+ if value in ads_cols or (value == 'R6' and r6_available)
1199
+ ]
1200
+
1201
+ @property
1202
+ def charts(self) -> StudyChartAccessor:
1203
+ """
1204
+ Accessor for IDE auto-completion of primary chart types.
1205
+
1206
+ Primary charts are the standard process behavior charts:
1207
+ Xbar, S, X, mR
1208
+
1209
+ For residual charts (VAS analysis), use study.residuals instead.
1210
+
1211
+ Usage:
1212
+ study.charts.Xbar # Auto-completes valid primary charts
1213
+ study.charts.X
1214
+
1215
+ Returns
1216
+ -------
1217
+ StudyChartAccessor
1218
+ Object with primary chart types as attributes
1219
+ """
1220
+ return StudyChartAccessor(self.valid_charts)
1221
+
1222
+ @property
1223
+ def residuals(self) -> StudyResidualAccessor:
1224
+ """
1225
+ Available VAS residuals for this study.
1226
+
1227
+ Provides IDE auto-completion for residual types that can be
1228
+ passed to execute(value=...). Only includes residuals whose
1229
+ columns were actually computed (requires factors + time).
1230
+
1231
+ - R1: Total residual (y - grand mean)
1232
+ - R2: Within-subgroup variation (measurement noise)
1233
+ - R3: Interaction effects (factor × time)
1234
+ - R4: Time effects (trends, shifts over time)
1235
+ - R5: Factor effects (differences between levels)
1236
+
1237
+ Usage:
1238
+ study.execute(chart=study.charts.Xbar, value=study.residuals.R5)
1239
+ study.execute(chart='X', value=study.residuals.R4, recentered=True)
1240
+
1241
+ Returns
1242
+ -------
1243
+ StudyResidualAccessor
1244
+ Accessor with available residuals as attributes (e.g., .R2, .R5)
1245
+
1246
+ See Also
1247
+ --------
1248
+ residual_charts : Full list of residual+chart combinations (internal)
1249
+ """
1250
+ ads_cols = set(self._ads.analysis_dataset.columns)
1251
+ available = sorted(r for r in ('R1', 'R2', 'R3', 'R4', 'R5') if r in ads_cols)
1252
+ if self._spec.rsg_vars_list and 'R5' in ads_cols:
1253
+ available.append('R6')
1254
+ return StudyResidualAccessor(available)
1255
+
1256
+ @property
1257
+ def available_analysis_methods(self) -> pd.DataFrame:
1258
+ """
1259
+ Analysis methods available for this study beyond charts.
1260
+
1261
+ Returns a DataFrame listing Capability, Loss Function, and
1262
+ Maximum Information with availability based on the ADS.
1263
+
1264
+ Returns
1265
+ -------
1266
+ pd.DataFrame
1267
+ Columns: method, available, description
1268
+ """
1269
+ import pandas as pd
1270
+
1271
+ ads_cols = set(self._ads.analysis_dataset.columns)
1272
+ vas_available = 'R2' in ads_cols
1273
+
1274
+ rows = [
1275
+ {
1276
+ 'method': 'Capability',
1277
+ 'available': self.analytical_design_state.sds > 0,
1278
+ 'description': 'Process capability against specification limits (Ch. 16)',
1279
+ },
1280
+ {
1281
+ 'method': 'Loss Function',
1282
+ 'available': vas_available,
1283
+ 'description': 'Taguchi loss decomposition into 5 components (Ch. 15)',
1284
+ },
1285
+ {
1286
+ 'method': 'Maximum Information',
1287
+ 'available': vas_available,
1288
+ 'description': 'Noise floor analysis via R2 X + histogram',
1289
+ },
1290
+ ]
1291
+ return pd.DataFrame(rows)
1292
+
1293
+ @property
1294
+ def support(self) -> pd.DataFrame:
1295
+ """
1296
+ Chart support matrix for this study.
1297
+
1298
+ Returns a DataFrame with one row per chart type showing availability,
1299
+ recommendations, and explanations. This is the single source of truth
1300
+ for chart capabilities.
1301
+
1302
+ Returns
1303
+ -------
1304
+ pd.DataFrame
1305
+ Columns: chart, value, category, available, recommended, reason, question
1306
+
1307
+ For primary charts, ``value`` is None. For residual charts,
1308
+ ``value`` is the residual name (e.g. 'R2', 'R5').
1309
+
1310
+ Examples
1311
+ --------
1312
+ >>> study.support
1313
+ chart value category available recommended ...
1314
+ 0 Xbar None primary True True ...
1315
+ 1 S None primary True False ...
1316
+ 2 Xbar R5 residual True False ...
1317
+
1318
+ >>> study.support[study.support['available']] # Filter to available
1319
+ >>> study.support.query("category == 'residual'") # Residual charts
1320
+ """
1321
+ import pandas as pd
1322
+
1323
+ from .sds_detector import SDSAnalysisPlan
1324
+
1325
+ rows = []
1326
+
1327
+ # All possible primary charts
1328
+ ALL_PRIMARY = ['Xbar', 'S', 'X', 'mR', 'Histogram']
1329
+
1330
+ # All possible residual charts as (chart_type, residual) tuples. Named _PAIRS to
1331
+ # keep it distinct from spc_constants.ALL_RESIDUALS, which is a set of residual
1332
+ # codes — different concept, and now one import away.
1333
+ # R1 is location-only (Xbar/X): it is the response shifted by -Ybar, so an S or mR
1334
+ # chart of R1 is numerically identical to the response's and adds no diagnostic.
1335
+ ALL_RESIDUAL_PAIRS = [
1336
+ ('Xbar', 'R1'),
1337
+ ('X', 'R1'),
1338
+ ('S', 'R2'),
1339
+ ('X', 'R2'),
1340
+ ('Xbar', 'R3'),
1341
+ ('S', 'R3'),
1342
+ ('X', 'R3'),
1343
+ ('Xbar', 'R4'),
1344
+ ('S', 'R4'),
1345
+ ('X', 'R4'),
1346
+ ('Xbar', 'R5'),
1347
+ ('S', 'R5'),
1348
+ ('X', 'R5'),
1349
+ ('Xbar', 'R6'),
1350
+ ('S', 'R6'),
1351
+ ]
1352
+
1353
+ # ADS=0 guard: all charts unavailable
1354
+ if self.analytical_design_state.sds == 0:
1355
+ for chart in ALL_PRIMARY:
1356
+ rows.append(
1357
+ {
1358
+ 'chart': chart,
1359
+ 'value': None,
1360
+ 'category': 'primary',
1361
+ 'available': False,
1362
+ 'recommended': False,
1363
+ 'reason': 'No valid observations after data cleaning',
1364
+ 'question': SDSAnalysisPlan.CHART_QUESTIONS.get(chart, ''),
1365
+ }
1366
+ )
1367
+ for chart, value in ALL_RESIDUAL_PAIRS:
1368
+ rows.append(
1369
+ {
1370
+ 'chart': chart,
1371
+ 'value': value,
1372
+ 'category': 'residual',
1373
+ 'available': False,
1374
+ 'recommended': False,
1375
+ 'reason': 'No valid observations after data cleaning',
1376
+ 'question': SDSAnalysisPlan.CHART_QUESTIONS.get((chart, value), ''),
1377
+ }
1378
+ )
1379
+ return pd.DataFrame(rows)
1380
+
1381
+ # Build invalid_reasons dict from _plan.invalid_charts
1382
+ invalid_reasons = self._parse_invalid_charts()
1383
+
1384
+ for chart in ALL_PRIMARY:
1385
+ # Availability here is about charting *the response*, which is what
1386
+ # a bare execute(chart=...) does — so it must account for the
1387
+ # replication rule execute() enforces, not just chart-family
1388
+ # validity. See _response_pair_problem.
1389
+ response_problem = self._response_pair_problem(chart) if chart in self.valid_charts else None
1390
+ rows.append(
1391
+ {
1392
+ 'chart': chart,
1393
+ 'value': None,
1394
+ 'category': 'primary',
1395
+ 'available': chart in self.valid_charts and response_problem is None,
1396
+ 'recommended': chart == self.recommended_chart,
1397
+ 'reason': invalid_reasons.get(chart) or response_problem,
1398
+ 'question': SDSAnalysisPlan.CHART_QUESTIONS.get(chart, ''),
1399
+ }
1400
+ )
1401
+
1402
+ for chart, value in ALL_RESIDUAL_PAIRS:
1403
+ available = (chart, value) in self.residual_charts
1404
+ rows.append(
1405
+ {
1406
+ 'chart': chart,
1407
+ 'value': value,
1408
+ 'category': 'residual',
1409
+ 'available': available,
1410
+ 'recommended': False,
1411
+ 'reason': None if available else 'Not available for this SDS',
1412
+ 'question': SDSAnalysisPlan.CHART_QUESTIONS.get((chart, value), ''),
1413
+ }
1414
+ )
1415
+
1416
+ return pd.DataFrame(rows)
1417
+
1418
+ def _parse_invalid_charts(self) -> dict[str, str]:
1419
+ """
1420
+ Parse invalid_charts list into dict of chart → reason.
1421
+
1422
+ The _plan.invalid_charts format is: ['S (requires n≥2 per subgroup)']
1423
+ This parses to: {'S': 'requires n≥2 per subgroup'}
1424
+ """
1425
+ result = {}
1426
+ for entry in self._plan.invalid_charts:
1427
+ # Format: 'ChartType (reason)'
1428
+ if '(' in entry and entry.endswith(')'):
1429
+ chart = entry.split('(')[0].strip()
1430
+ reason = entry[entry.index('(') + 1 : -1]
1431
+ result[chart] = reason
1432
+ return result
1433
+
1434
+ # =========================================================================
1435
+ # Guidance Methods
1436
+ # =========================================================================
1437
+
1438
+ def why_not(self, chart: str, value: str | None = None) -> str:
1439
+ """
1440
+ Explain why a chart type is or isn't available for this study.
1441
+
1442
+ This is a teaching method - it helps users understand the
1443
+ methodology by explaining constraints. Uses the support DataFrame
1444
+ as the single source of truth.
1445
+
1446
+ Parameters
1447
+ ----------
1448
+ chart : str
1449
+ Chart type to check (e.g., 'X', 'S', 'Xbar')
1450
+ value : str, optional
1451
+ Residual to check (e.g., 'R2', 'R5'). Required for residual
1452
+ chart queries.
1453
+
1454
+ Returns
1455
+ -------
1456
+ str
1457
+ Explanation of availability with the question the chart answers
1458
+
1459
+ Examples
1460
+ --------
1461
+ >>> study.why_not('S')
1462
+ "'S' unavailable: requires n≥2 per subgroup"
1463
+
1464
+ >>> study.why_not('Xbar')
1465
+ "'Xbar' IS available. Are subgroup means stable over time?"
1466
+
1467
+ >>> study.why_not('Xbar', value='R5')
1468
+ "'Xbar' (R5) IS available. Do factors have a significant effect on the mean?"
1469
+
1470
+ A recognised chart in an invalid pairing names the charts that DO work — the same
1471
+ answer ``execute()`` gives, because both read one predicate:
1472
+
1473
+ >>> study.why_not('Xbar', value='R2')
1474
+ "'Xbar' with value='R2' is not valid for ADS 1.\\nValid charts for R2: S, X"
1475
+ """
1476
+ # Handle old fused syntax: why_not('R5_Xbar') -> why_not('Xbar', value='R5')
1477
+ if '_' in chart and value is None:
1478
+ parts = chart.split('_', 1)
1479
+ if parts[0].startswith('R') and len(parts[0]) >= 2 and parts[0][1:].isdigit():
1480
+ value, chart = parts[0], parts[1]
1481
+
1482
+ # ADS=0: no valid observations after cleaning
1483
+ if self.analytical_design_state.sds == 0:
1484
+ label = f"'{chart}'" if value is None else f"'{chart}' ({value})"
1485
+ return f'{label} unavailable: no valid observations after data cleaning'
1486
+
1487
+ label = f"'{chart}'" if value is None else f"'{chart}' ({value})"
1488
+
1489
+ # Unrecognised chart name — distinct from a recognised chart in an invalid
1490
+ # combination, which is the case this method used to conflate it with.
1491
+ if chart not in VALID_BASE_CHARTS:
1492
+ return (
1493
+ f"'{chart}' is not a recognized chart type. "
1494
+ f'Valid charts: {", ".join(sorted(VALID_BASE_CHARTS))}.'
1495
+ )
1496
+
1497
+ if value is not None:
1498
+ base_residual = _base_residual_code(value)
1499
+ if base_residual not in RESIDUAL_CODES:
1500
+ return (
1501
+ f"'{value}' is not a recognized residual. "
1502
+ f'Valid values: {", ".join(RESIDUAL_CODES)} '
1503
+ f"(prefix with 'RC' for the recentered form, e.g. 'RCR5')."
1504
+ )
1505
+
1506
+ # Same predicate execute() validates against, so the two cannot disagree.
1507
+ problem = self._residual_pair_problem(chart, value)
1508
+ if problem:
1509
+ return problem
1510
+ if chart == 'mR':
1511
+ return (
1512
+ f'{label} IS available. Is the moving range of {value} stable? '
1513
+ f"Add companion=True to get the X chart alongside it."
1514
+ )
1515
+ from .sds_detector import SDSAnalysisPlan
1516
+
1517
+ question = SDSAnalysisPlan.CHART_QUESTIONS.get((chart, base_residual), '')
1518
+ return f'{label} IS available. {question}'.rstrip()
1519
+
1520
+ # Primary chart: the support row carries availability, reason and question.
1521
+ df = self.support
1522
+ row = df[(df['chart'] == chart) & (df['value'].isna())]
1523
+ if row.empty:
1524
+ return f'{label} is not a recognized chart type. Use study.support to see all options.'
1525
+
1526
+ row = row.iloc[0]
1527
+ if row['available']:
1528
+ return f'{label} IS available. {row["question"]}'.rstrip()
1529
+ else:
1530
+ return f'{label} unavailable: {row["reason"]}'
1531
+
1532
+ def design(self) -> DesignReport:
1533
+ """
1534
+ Get design report comparing sampling plan to observed data.
1535
+
1536
+ Returns a DesignReport showing factors, levels, and any mismatches
1537
+ between planned and observed structure. Works with or without a
1538
+ sampling plan:
1539
+
1540
+ - With plan: Shows planned vs observed, highlights missing/extra levels
1541
+ - Without plan: Shows observed structure only
1542
+
1543
+ Note: Time Handling
1544
+ -------------------
1545
+ The sampling plan specifies factor levels only, not time levels.
1546
+ For SDS detection, observed unique time values are used as the
1547
+ "planned" time set. This means coverage detects missing factor
1548
+ combos within observed time blocks, but time points not in the
1549
+ data are not considered "missing."
1550
+
1551
+ Returns
1552
+ -------
1553
+ DesignReport
1554
+ Object with:
1555
+ - factors: DataFrame with planned/observed/missing/extra per factor
1556
+ - missing_levels: dict of levels in plan but not observed
1557
+ - extra_levels: dict of levels observed but not in plan
1558
+ - has_plan: whether a sampling plan was provided
1559
+ - K, K_observed, K_missing: RSG group counts
1560
+ - T, T_observed, T_missing: Time point counts (if T specified)
1561
+ - N, N_observed: Cell size info (if N specified)
1562
+ - missing_combos, extra_combos: RSG string lists
1563
+ - sds_reason, structure_summary: Diagnostic info
1564
+
1565
+ Examples
1566
+ --------
1567
+ >>> study = pb.formulate(
1568
+ ... response=pb.cols.Weight,
1569
+ ... time=pb.cols.Pull,
1570
+ ... plan={
1571
+ ... 'factors': {pb.cols.Lane: [1,2,3,4], pb.cols.Phase: [1,2,3]},
1572
+ ... 'T': 10,
1573
+ ... 'N': 2
1574
+ ... }
1575
+ ... )
1576
+ >>> design = study.design()
1577
+ >>> design
1578
+ DesignReport(2 factors, with plan)
1579
+ SDS reason: incomplete_no_singletons
1580
+ K: planned=12, observed=8, missing=4
1581
+ T: planned=10, observed=8, missing=2
1582
+ N: planned=2, observed=(min=1, median=2.0, max=3)
1583
+
1584
+ Factors:
1585
+ Lane: planned=[1,2,3,4], observed=[1,2,3,4]
1586
+ Phase: planned=[1,2,3], observed=[1,2], missing=[3]
1587
+
1588
+ Structure: Incomplete: 4 RSG groups missing; 2 time points missing
1589
+
1590
+ >>> design.missing_levels
1591
+ {'Lane': [], 'Phase': [3]}
1592
+
1593
+ >>> design.K
1594
+ 12
1595
+ >>> design.missing_combos
1596
+ ['1_3', '2_3', '3_3', '4_3']
1597
+
1598
+ Without a plan (shows observed structure):
1599
+
1600
+ >>> study = pb.formulate(response='weight', factors=['lane', 'phase'])
1601
+ >>> study.design()
1602
+ DesignReport(2 factors, observed only)
1603
+ K: observed=4
1604
+ ...
1605
+ """
1606
+ # _factors MUST be spec.rsg_vars order (not plan dict order)
1607
+ # to ensure correct cartesian product encoding
1608
+ factors = self._spec.rsg_vars_list
1609
+
1610
+ # Use analysis dataset (post-filtering, NA-handled) for all observed values
1611
+ ads_df = self._ads.analysis_dataset
1612
+
1613
+ # Get observed levels from analysis dataset
1614
+ observed_levels: dict[str, list] = {}
1615
+ for factor in factors:
1616
+ if factor in ads_df.columns:
1617
+ values = ads_df[factor].dropna().unique()
1618
+ try:
1619
+ observed_levels[factor] = sorted(values.tolist())
1620
+ except TypeError:
1621
+ observed_levels[factor] = list(values)
1622
+ else:
1623
+ observed_levels[factor] = []
1624
+
1625
+ # Compute T_observed from analysis dataset
1626
+ T_observed = None
1627
+ if self._spec.time_var and self._spec.time_var in ads_df.columns:
1628
+ T_observed = int(ads_df[self._spec.time_var].nunique())
1629
+
1630
+ # Compute observed RSG values, N_observed, and R_observed
1631
+ N_observed = None
1632
+ R_observed = None
1633
+ observed_rsg_values: list[str] = []
1634
+ rsg_col = self._spec.rsg_var_name
1635
+
1636
+ if rsg_col and rsg_col in ads_df.columns:
1637
+ # Get actual rsg values from analysis dataset
1638
+ observed_rsg_values = ads_df[rsg_col].unique().tolist()
1639
+
1640
+ # Cell sizes: group by (rsg, time) or just rsg
1641
+ if self._spec.time_var and self._spec.time_var in ads_df.columns:
1642
+ cell_sizes = ads_df.groupby([rsg_col, self._spec.time_var], observed=True).size()
1643
+ # R_observed = count of unique (rsg, time) cells
1644
+ R_observed = len(cell_sizes)
1645
+ else:
1646
+ cell_sizes = ads_df.groupby([rsg_col], observed=True).size()
1647
+
1648
+ # N_observed = (min, median, max)
1649
+ if len(cell_sizes) > 0:
1650
+ N_observed = (int(cell_sizes.min()), float(cell_sizes.median()), int(cell_sizes.max()))
1651
+
1652
+ return DesignReport(
1653
+ _sampling_plan=self._sampling_plan,
1654
+ _observed_levels=observed_levels,
1655
+ _factors=factors,
1656
+ _delim=self._spec.rsg_var_delim,
1657
+ _T=self._T,
1658
+ _N=self._N,
1659
+ _T_observed=T_observed,
1660
+ _N_observed=N_observed,
1661
+ _observed_rsg_values=observed_rsg_values,
1662
+ _sds_reason=self._sds_result.reason if self._sds_result else None,
1663
+ _unit_of_analysis=self._spec.unit_of_analysis,
1664
+ _R_observed=R_observed,
1665
+ _min_cell_size=self._sds_result.min_cell_size if self._sds_result else None,
1666
+ _n_empty_cells=self._sds_result.n_empty_cells if self._sds_result else None,
1667
+ _pds_result=self._pds_result,
1668
+ _ods_result=self._sds_result,
1669
+ _ads_result=self._ads.analytical_design_state,
1670
+ )
1671
+
1672
+ def capability(
1673
+ self,
1674
+ specs: SpecLimits | None = None,
1675
+ *,
1676
+ usl: float | None = None,
1677
+ lsl: float | None = None,
1678
+ target: float | None = None,
1679
+ window: tuple | None = None,
1680
+ ) -> CapabilityResult:
1681
+ """
1682
+ Assess process capability against specification limits (Ch. 16).
1683
+
1684
+ Parameters
1685
+ ----------
1686
+ specs : SpecLimits, optional
1687
+ Pre-built specification limits. If provided, keyword arguments
1688
+ are ignored.
1689
+ usl : float, optional
1690
+ Upper specification limit.
1691
+ lsl : float, optional
1692
+ Lower specification limit.
1693
+ target : float, optional
1694
+ Target value.
1695
+ window : tuple, optional
1696
+ Time-based subset ``(start, end)`` in the units of this study's
1697
+ designated time variable (an int for a sequence, a date/datetime for
1698
+ a date axis); half-open ``start <= t < end``, either bound ``None``
1699
+ for open. Default ``None`` = full data (unchanged behaviour). This is
1700
+ a view over the frozen analytic dataset: current capability and the
1701
+ potential centering use the windowed observed values, while the
1702
+ potential noise floor (σ̂_R2) stays the full-study pooled residual
1703
+ basis. See ``assess_capability`` for the windowing rules/guards.
1704
+
1705
+ Returns
1706
+ -------
1707
+ CapabilityResult
1708
+ Frozen dataclass with Pp/Ppk, Cp/Cpk (when R2 available),
1709
+ Z-scores, and empirical percent outside.
1710
+
1711
+ Examples
1712
+ --------
1713
+ >>> cap = study.capability(usl=250.5, lsl=249.5, target=250.0)
1714
+ >>> cap.ppk
1715
+ 1.42
1716
+
1717
+ >>> cap2 = study.capability(usl=251.0, lsl=249.0) # wider specs
1718
+
1719
+ Before vs. after a change at time 25 (integer time axis):
1720
+
1721
+ >>> before = study.capability(usl=240, target=180, window=(None, 25))
1722
+ >>> after = study.capability(usl=240, target=180, window=(25, None))
1723
+ """
1724
+ from .capability import SpecLimits as _SpecLimits
1725
+ from .capability import assess_capability
1726
+
1727
+ if specs is None:
1728
+ specs = _SpecLimits(usl=usl, lsl=lsl, target=target)
1729
+ return assess_capability(self._ads, specs, round_to=self._spec.round_to, window=window)
1730
+
1731
+ def loss_function(self, target: float | None = None) -> LossResult:
1732
+ """
1733
+ Assess Taguchi Loss Function decomposition (Ch. 15).
1734
+
1735
+ Decomposes expected loss into 5 components: centering, unexplained,
1736
+ PDC, time, and PDC×time interaction.
1737
+
1738
+ Parameters
1739
+ ----------
1740
+ target : float, optional
1741
+ Target value. Defaults to grand mean (centering = 0).
1742
+
1743
+ Returns
1744
+ -------
1745
+ LossResult
1746
+ Frozen dataclass with loss decomposition and Pareto percentages.
1747
+
1748
+ Examples
1749
+ --------
1750
+ >>> result = study.loss_function(target=237.0)
1751
+ >>> result.pct_interaction # largest driver?
1752
+ 43.3
1753
+ """
1754
+ from .loss_function import assess_loss
1755
+
1756
+ return assess_loss(self._ads, target=target, round_to=self._spec.round_to)
1757
+
1758
+ def maximum_information(self) -> MaximumInformationResult:
1759
+ """
1760
+ Maximum information analysis of R2 residuals (Bishop).
1761
+
1762
+ Examines the noise floor via an X chart and percentage histogram.
1763
+
1764
+ Returns
1765
+ -------
1766
+ MaximumInformationResult
1767
+ Frozen dataclass with noise-floor statistics and ``.plot()`` method.
1768
+
1769
+ Examples
1770
+ --------
1771
+ >>> mi = study.maximum_information()
1772
+ >>> mi.plot() # Combined X + histogram
1773
+ >>> mi.plot(view='histogram') # Percentage histogram only
1774
+ """
1775
+ from .maximum_information import assess_maximum_information
1776
+
1777
+ return assess_maximum_information(self._ads, round_to=self._spec.round_to)
1778
+
1779
+ def execute(
1780
+ self,
1781
+ chart: Literal['Histogram', 'Xbar', 'S', 'X', 'mR'] | str | None = None,
1782
+ by: list[str] | None = None,
1783
+ value: Literal['response', 'R1', 'R2', 'R3', 'R4', 'R5', 'R6'] | str | None = None,
1784
+ recentered: bool = False,
1785
+ bins: int | None = None,
1786
+ companion: bool = False,
1787
+ phased: bool = False,
1788
+ n_sigma: float = 3.0,
1789
+ n_mode: Literal['actual', 'average'] = 'actual',
1790
+ calibration: Calibration | str | None = None,
1791
+ stratify_by: list[str] | None = None,
1792
+ ) -> AnalysisResult:
1793
+ """
1794
+ Run the analysis and return results.
1795
+
1796
+ This executes the formulated study using the specified chart type
1797
+ (or the recommended chart if none specified). The `by` parameter
1798
+ controls how data is grouped/stratified, and `value` specifies
1799
+ what to chart (response or residuals).
1800
+
1801
+ IMPORTANT: `by` creates a VIEW over the immutable analytic dataset.
1802
+ It does NOT recompute residuals or change the decomposition of variation.
1803
+ Residual values are identical regardless of `by`.
1804
+
1805
+ Parameters
1806
+ ----------
1807
+ chart : str, optional
1808
+ Chart type. If None, uses recommended_chart.
1809
+ Valid charts: 'Xbar', 'S', 'X', 'mR', 'Histogram'.
1810
+ X is the focal (individual values) chart; mR is its companion
1811
+ (moving range). Use companion=True to get both together.
1812
+
1813
+ by : list[str], optional
1814
+ Factors to group/stratify by. Controls view granularity.
1815
+
1816
+ - by=None: Default for chart type (full rsg_key for Xbar/S,
1817
+ ERROR for X/mR with factors - must be explicit)
1818
+ - by=[]: For X/mR: single overall chart (collapse all factors).
1819
+ For Xbar/S: equivalent to by=None (cell-level grouping).
1820
+ - by=['factor']: Stratify/aggregate by single factor
1821
+ - by=['f1', 'f2']: Stratify/aggregate by factor combination
1822
+
1823
+ For Xbar/S: by controls aggregation (groups on x-axis).
1824
+ Special case: by=[time_var] with factors stratifies by
1825
+ factor combinations, producing one chart per combo with
1826
+ time on x-axis. Only applies to response (not residuals).
1827
+ For X/mR: by controls stratification (separate charts)
1828
+
1829
+ value : str, optional
1830
+ What to chart. Options:
1831
+
1832
+ - None or 'response': Chart the response variable (default)
1833
+ - 'R1' through 'R5': Chart the specified residual
1834
+
1835
+ recentered : bool, default False
1836
+ For residual charts only. If True, charts the re-centered
1837
+ residual RCRk = Rk + baseline_k, shifting from a zero-centered
1838
+ scale to the response scale for easier interpretation. The
1839
+ baseline for each residual is defined by the VAS decomposition
1840
+ (Bishop VAS §15.5). Requires VAS decomposition
1841
+ (factors + time).
1842
+
1843
+ bins : int, optional
1844
+ Number of bins for Histogram charts. Defaults to 10.
1845
+ Only applicable to Histogram; providing bins for other chart types
1846
+ raises ValidationError.
1847
+
1848
+ companion : bool, default False
1849
+ Returns both the location chart and its companion range chart
1850
+ in a single result, regardless of which is requested
1851
+ (e.g., chart='mR', companion=True returns both X and mR).
1852
+ Each chart is accessed separately via get_chart().
1853
+ Not applicable to Histogram.
1854
+
1855
+ phased : bool, default False
1856
+ When True, computes per-phase center lines and control limits.
1857
+ Each phase is a contiguous run of the same collapsed factor
1858
+ combination (the groups demarcated by vertical lane boundaries).
1859
+ Within each phase, the moving range is computed independently.
1860
+
1861
+ - phased=False (default): Global limits across entire chart
1862
+ - phased=True: Per-phase limits (requires by=[])
1863
+
1864
+ Single-point phases yield zero-width limits because mR is
1865
+ imputed as 0; count available in metadata['single_point_phases'].
1866
+
1867
+ n_sigma : float, default 3.0
1868
+ Sigma multiplier for Xbar/S chart control limits. Standard SPC
1869
+ uses 3-sigma limits; smaller values give narrower limits (more
1870
+ sensitive), larger values give wider limits (fewer false signals).
1871
+ Only valid for Xbar/S charts.
1872
+
1873
+ n_mode : str, default "actual"
1874
+ How to determine subgroup size N for Xbar/S limit calculations.
1875
+
1876
+ - "actual": Use each subgroup's actual size N_k (default).
1877
+ When sizes vary, limits vary per subgroup ("Varies" in statistics).
1878
+ - "average": Use the mean subgroup size N-bar for all subgroups.
1879
+ Produces constant limits even when subgroup sizes vary.
1880
+
1881
+ Only valid for Xbar/S charts.
1882
+
1883
+ calibration : Calibration or str, optional
1884
+ Apply standards-given limits using a frozen mean/sigma instead of
1885
+ data-derived estimates. Accepts a :class:`Calibration` object or the
1886
+ label of one previously attached via :meth:`with_calibration`.
1887
+ Location charts (X, Xbar, response, residuals) place limits at
1888
+ ``center ± n_sigma·sigma`` (Xbar divides by ``√N``); dispersion
1889
+ charts (S, mR) use the standards-given sampling-distribution form.
1890
+ X/mR remain 3-sigma, so a calibrated X/mR rejects a non-default
1891
+ ``n_sigma`` (calibration is not a back door to non-3-sigma limits).
1892
+
1893
+ Notes
1894
+ -----
1895
+ **Xbar center line (Bishop VAS unweighted).** For both response and
1896
+ residual Xbar charts, the center line is the mean of (factor x time)
1897
+ cell means on the charted column — equal weight per experimental
1898
+ condition, regardless of how many observations fall in each cell. This
1899
+ is the VAS-canonical grand mean and matches Bishop's Minitab reference.
1900
+ It is **not** the observation-weighted mean and **not** the mean of the
1901
+ plotted subgroup means. On balanced designs the three coincide; on
1902
+ unbalanced designs they differ slightly, but per Bishop the practical
1903
+ difference is negligible — the methodology still requires the
1904
+ unweighted form.
1905
+
1906
+ Returns
1907
+ -------
1908
+ AnalysisResult
1909
+ Complete analysis results with charts, statistics, residuals.
1910
+ Use result.plot() to visualize or result.to_excel() to export.
1911
+
1912
+ Raises
1913
+ ------
1914
+ ChartNotAvailableError
1915
+ If specified chart is not valid for this SDS
1916
+ ValidationError
1917
+ If by contains invalid factors, or if X/mR with factors
1918
+ requires an explicit by parameter that wasn't supplied
1919
+
1920
+ Examples
1921
+ --------
1922
+ Use recommended chart:
1923
+
1924
+ >>> result = study.execute()
1925
+
1926
+ Specify chart explicitly:
1927
+
1928
+ >>> result = study.execute(chart='Xbar')
1929
+ >>> result = study.execute(chart=study.charts.Xbar)
1930
+
1931
+ Use by parameter for views:
1932
+
1933
+ >>> # Xbar aggregated by factor 1
1934
+ >>> result = study.execute(chart='Xbar', by=['factor 1'])
1935
+
1936
+ >>> # X chart stratified by all factors (separate chart per combo)
1937
+ >>> result = study.execute(chart='X', by=['factor 1', 'factor 2'])
1938
+
1939
+ >>> # X chart single overall
1940
+ >>> result = study.execute(chart='X', by=[])
1941
+
1942
+ >>> # Stratified Xbar by time (one chart per factor combination)
1943
+ >>> result = study.execute(chart='Xbar', by=['PRODUCTION TIME'])
1944
+
1945
+ Chart residuals:
1946
+
1947
+ >>> result = study.execute(chart='Xbar', value='R5') # Factor effects
1948
+ >>> result = study.execute(chart='X', value='R4', recentered=True)
1949
+
1950
+ Chain to visualization:
1951
+
1952
+ >>> study.execute().plot()
1953
+ """
1954
+ # Import here to avoid circular imports
1955
+ from .analysis import Analysis
1956
+ from .formulation_spec import ChartRequest
1957
+
1958
+ # 0a. `stratify_by` is an explicit spelling of `by` for the X/mR regime, where the
1959
+ # argument means "split into separate charts" rather than "compose the subgroup".
1960
+ # Same parameter underneath — the alias exists so the call site says which of the
1961
+ # two operations is intended, since the return shape differs between them.
1962
+ if stratify_by is not None:
1963
+ if by is not None:
1964
+ raise ValidationError(
1965
+ "Pass either 'by' or 'stratify_by', not both — they set the same "
1966
+ "parameter.\n"
1967
+ "'stratify_by' is the explicit spelling for X/mR (separate chart per "
1968
+ "stratum); 'by' also composes subgroups for Xbar/S."
1969
+ )
1970
+ by = stratify_by
1971
+
1972
+ # 0. Resolve calibration (label string -> attached Calibration object).
1973
+ calibration_obj = self._resolve_calibration(calibration)
1974
+
1975
+ # 1. Resolve chart name (None → recommended, basic shape check).
1976
+ base_chart = self._resolve_execute_chart(chart)
1977
+
1978
+ # 2. Run all parameter validation; resolve by/value/is_residual.
1979
+ by_validated, value_col, is_residual = self._validate_execute_request(
1980
+ base_chart=base_chart,
1981
+ by=by,
1982
+ value=value,
1983
+ recentered=recentered,
1984
+ companion=companion,
1985
+ bins=bins,
1986
+ phased=phased,
1987
+ n_sigma=n_sigma,
1988
+ n_mode=n_mode,
1989
+ )
1990
+
1991
+ # 3. Build ephemeral request and dispatch to Analysis.
1992
+ request = ChartRequest(
1993
+ chart=base_chart,
1994
+ by=tuple(by_validated) if by_validated is not None else None,
1995
+ value_col=value_col,
1996
+ residual=value.upper() if is_residual else None,
1997
+ residual_chart_type=base_chart if is_residual else None,
1998
+ recentered=recentered,
1999
+ companion=companion,
2000
+ bins=bins,
2001
+ phased=phased,
2002
+ n_sigma=n_sigma,
2003
+ n_mode=n_mode,
2004
+ calibration=calibration_obj,
2005
+ )
2006
+ analysis = Analysis(self._spec, request, analysis_dataset=self._ads)
2007
+ return analysis.calculate()
2008
+
2009
+ def supports_calibration(
2010
+ self,
2011
+ chart: Literal['Histogram', 'Xbar', 'S', 'X', 'mR'] | str | None = None,
2012
+ by: list[str] | None = None,
2013
+ value: Literal['response', 'R1', 'R2', 'R3', 'R4', 'R5', 'R6'] | str | None = None,
2014
+ recentered: bool = False,
2015
+ bins: int | None = None,
2016
+ companion: bool = False,
2017
+ phased: bool = False,
2018
+ n_sigma: float = 3.0,
2019
+ n_mode: Literal['actual', 'average'] = 'actual',
2020
+ stratify_by: list[str] | None = None,
2021
+ ) -> bool:
2022
+ """Would ``execute(..., calibration=cal)`` apply the calibration, or refuse it?
2023
+
2024
+ Takes the same arguments as :meth:`execute` (minus ``calibration`` itself —
2025
+ the answer depends on the chart path, not on which calibration) and answers
2026
+ without running the analysis, so a caller can enable a control or pick a
2027
+ code path before committing to the work.
2028
+
2029
+ **Use this instead of re-deriving the rule.** It is not the one-liner it
2030
+ looks like. Xbar/S stratify on ``by=[time]`` *only when charting the
2031
+ response*, so the same ``by`` is refused for the response and accepted for a
2032
+ residual; X/mR stratify whenever ``by`` is non-empty **or absent**, and
2033
+ ``by=[]`` is the collapsed case that phasing then rules out. A hand-written
2034
+ copy of this predicate is wrong on the first of those rows.
2035
+
2036
+ Returns
2037
+ -------
2038
+ bool
2039
+ True if a calibration would be applied; False if
2040
+ :class:`~processbehavior.exceptions.CalibrationNotSupportedError`
2041
+ would be raised.
2042
+
2043
+ Raises
2044
+ ------
2045
+ ValidationError, ChartNotAvailableError
2046
+ If the request is not executable at all. The question "can this be
2047
+ calibrated?" has no answer for a chart that cannot be produced, so it
2048
+ fails the same way :meth:`execute` would rather than returning False.
2049
+
2050
+ Examples
2051
+ --------
2052
+ >>> study.supports_calibration(chart='X', by=[])
2053
+ True
2054
+ >>> study.supports_calibration(chart='X', by=['Machine'])
2055
+ False
2056
+
2057
+ See Also
2058
+ --------
2059
+ execute : Runs the analysis; raises when a calibration cannot be applied.
2060
+ """
2061
+ if stratify_by is not None:
2062
+ if by is not None:
2063
+ raise ValidationError(
2064
+ "Pass either 'by' or 'stratify_by', not both — they set the same "
2065
+ 'parameter.'
2066
+ )
2067
+ by = stratify_by
2068
+
2069
+ from .analysis import Analysis
2070
+ from .formulation_spec import ChartRequest
2071
+
2072
+ base_chart = self._resolve_execute_chart(chart)
2073
+ by_validated, value_col, is_residual = self._validate_execute_request(
2074
+ base_chart=base_chart,
2075
+ by=by,
2076
+ value=value,
2077
+ recentered=recentered,
2078
+ companion=companion,
2079
+ bins=bins,
2080
+ phased=phased,
2081
+ n_sigma=n_sigma,
2082
+ n_mode=n_mode,
2083
+ )
2084
+ request = ChartRequest(
2085
+ chart=base_chart,
2086
+ by=tuple(by_validated) if by_validated is not None else None,
2087
+ value_col=value_col,
2088
+ residual=value.upper() if is_residual else None,
2089
+ residual_chart_type=base_chart if is_residual else None,
2090
+ recentered=recentered,
2091
+ companion=companion,
2092
+ bins=bins,
2093
+ phased=phased,
2094
+ n_sigma=n_sigma,
2095
+ n_mode=n_mode,
2096
+ )
2097
+ analysis = Analysis(self._spec, request, analysis_dataset=self._ads)
2098
+ return analysis.calibration_rejection_reason() is None
2099
+
2100
+ def _resolve_calibration(self, calibration: Calibration | str | None) -> Calibration | None:
2101
+ """Resolve a calibration argument to a Calibration object (or None).
2102
+
2103
+ A string is treated as a label and looked up in ``self.calibrations``;
2104
+ a missing label raises a self-diagnostic ValidationError listing what is
2105
+ attached. A Calibration object is returned as-is.
2106
+ """
2107
+ from .calibration import Calibration as _Calibration
2108
+
2109
+ if calibration is None:
2110
+ return None
2111
+ if isinstance(calibration, _Calibration):
2112
+ return calibration
2113
+ if isinstance(calibration, str):
2114
+ try:
2115
+ return self.calibrations[calibration]
2116
+ except KeyError:
2117
+ available = sorted(self.calibrations)
2118
+ hint = f'Attached calibrations: {available}.' if available else (
2119
+ 'No calibrations are attached; use study.with_calibration(Calibration(...)) first.'
2120
+ )
2121
+ raise ValidationError(f"No calibration labeled {calibration!r}. {hint}") from None
2122
+ raise ValidationError(
2123
+ f'calibration must be a Calibration, a label string, or None; got {type(calibration).__name__}.'
2124
+ )
2125
+
2126
+ def with_calibration(self, calibration: Calibration) -> Study:
2127
+ """Return a new Study with ``calibration`` attached under its label.
2128
+
2129
+ Immutable: the current Study is unchanged. Re-attaching the same label
2130
+ replaces the prior entry. Select an attached calibration at execute time
2131
+ with ``study.execute(calibration='<label>')``.
2132
+ """
2133
+ from .calibration import Calibration as _Calibration
2134
+
2135
+ if not isinstance(calibration, _Calibration):
2136
+ raise ValidationError(
2137
+ f'with_calibration expects a Calibration; got {type(calibration).__name__}.'
2138
+ )
2139
+ updated = dict(self.calibrations)
2140
+ updated[calibration.label] = calibration
2141
+ return dataclasses.replace(self, calibrations=updated)
2142
+
2143
+ def _resolve_execute_chart(self, chart: str | None) -> str:
2144
+ """Resolve the requested chart name to a base chart type.
2145
+
2146
+ None → `recommended_chart` (or raise if none available).
2147
+ Validates basic shape and parses through `_parse_chart_request`.
2148
+ """
2149
+ if chart is None and self.recommended_chart is None:
2150
+ raise ValidationError(
2151
+ 'No charts available: no valid observations after data cleaning. '
2152
+ 'Check your data for missing or invalid response values.'
2153
+ )
2154
+ if chart is not None and (not isinstance(chart, str) or chart == ''):
2155
+ raise ValidationError('Chart name must be a non-empty string')
2156
+ chart_request = chart or self.recommended_chart
2157
+ return self._parse_chart_request(chart_request)
2158
+
2159
+ def _validate_execute_request( # noqa: C901
2160
+ self,
2161
+ *,
2162
+ base_chart: str,
2163
+ by: list[str] | None,
2164
+ value: str | None,
2165
+ recentered: bool,
2166
+ companion: bool,
2167
+ bins: int | None,
2168
+ phased: bool,
2169
+ n_sigma: float,
2170
+ n_mode: str,
2171
+ ) -> tuple[list[str] | None, str | None, bool]:
2172
+ """Run all execute() parameter validation; resolve by/value/is_residual.
2173
+
2174
+ Returns
2175
+ -------
2176
+ by_validated : list[str] | None
2177
+ The `by=` argument after validation. May be replaced with `[]`
2178
+ for Histogram (full-distribution default).
2179
+ value_col : str | None
2180
+ The column name to chart (response or residual).
2181
+ is_residual : bool
2182
+ True when `value` requests a residual.
2183
+
2184
+ Raises
2185
+ ------
2186
+ ValidationError, ChartNotAvailableError
2187
+ Any user-facing validation failure.
2188
+ """
2189
+ # by= validation (may raise)
2190
+ by_validated = self._validate_by_parameter(by, base_chart)
2191
+
2192
+ if recentered:
2193
+ self._validate_recentered(value)
2194
+ if phased:
2195
+ self._validate_phased(base_chart, by_validated)
2196
+
2197
+ if companion and base_chart == 'Histogram':
2198
+ raise ValidationError('companion is not applicable to Histogram charts.')
2199
+ if bins is not None and base_chart != 'Histogram':
2200
+ raise ValidationError(f"bins is only applicable to Histogram charts, not '{base_chart}'.")
2201
+
2202
+ self._validate_n_sigma_n_mode(n_sigma, n_mode, base_chart)
2203
+
2204
+ # Histogram with by=None defaults to the full distribution.
2205
+ if base_chart == 'Histogram' and by is None:
2206
+ by_validated = []
2207
+
2208
+ # Primary chart-vs-ADS validation.
2209
+ if base_chart not in self.valid_charts:
2210
+ available_list = list(self.valid_charts)
2211
+ raise ChartNotAvailableError(
2212
+ f"Chart type '{base_chart}' is not valid for ADS {self.analytical_design_state.sds}.\n"
2213
+ f'Valid charts: {", ".join(available_list)}\n'
2214
+ f'Recommended: {self.recommended_chart}\n'
2215
+ f"Use study.why_not('{base_chart}') for explanation.",
2216
+ chart=base_chart,
2217
+ available=available_list,
2218
+ )
2219
+
2220
+ # Resolve value column (response or residual; R6 is computed per request).
2221
+ is_r6 = value is not None and value.upper() == 'R6'
2222
+ if is_r6:
2223
+ # Raises the by=/factors ValidationErrors; validation stays pure — the
2224
+ # frame is untouched. Analysis re-derives the key when it computes.
2225
+ resolve_r6_groupby(by_validated, self._spec.rsg_vars_list)
2226
+ value_col = 'RCR6' if recentered else 'R6'
2227
+ else:
2228
+ value_col = self._resolve_value_column(value, recentered)
2229
+
2230
+ is_residual = value is not None and value.upper().startswith('R')
2231
+
2232
+ # Validate residual availability (Histogram exempt — plots any numeric column).
2233
+ # R6 never lives on the study frame; check its prerequisites instead — the
2234
+ # same predicate residual_charts uses, so execute() and why_not() agree.
2235
+ ads_columns = self._ads.analysis_dataset.columns
2236
+ residual_col_missing = (
2237
+ not {'R5', 'R2'}.issubset(ads_columns) if is_r6 else value_col not in ads_columns
2238
+ )
2239
+ if is_residual and base_chart != 'Histogram' and residual_col_missing:
2240
+ available_residuals = [
2241
+ c for c in ads_columns
2242
+ if c.upper().startswith('R') and c[1:2].isdigit()
2243
+ ]
2244
+ raise ChartNotAvailableError(
2245
+ f"Residual column '{value_col}' not found in the dataset "
2246
+ f'for ADS {self.analytical_design_state.sds}.\n'
2247
+ f'Available residual columns: {", ".join(available_residuals) if available_residuals else "None"}\n'
2248
+ f'Use study.residuals to see available options.',
2249
+ chart=value_col,
2250
+ available=available_residuals,
2251
+ )
2252
+
2253
+ # Validate (chart, value) combo. The rule itself lives in
2254
+ # _residual_pair_problem so why_not() answers from the same predicate — see
2255
+ # its docstring for the three regimes.
2256
+ if is_residual:
2257
+ problem = self._residual_pair_problem(base_chart, value)
2258
+ if problem:
2259
+ base_residual = _base_residual_code(value)
2260
+ valid_for_value = [c for c, v in self.residual_charts if v == base_residual]
2261
+ raise ChartNotAvailableError(
2262
+ f"{problem}\n"
2263
+ f"Use study.why_not('{base_chart}', value='{value}') for details.",
2264
+ chart=base_chart,
2265
+ available=valid_for_value,
2266
+ )
2267
+
2268
+ return by_validated, value_col, is_residual
2269
+
2270
+ def _response_pair_problem(self, base_chart: str) -> str | None:
2271
+ """Why ``base_chart`` can't chart *the response* — or ``None`` if it can.
2272
+
2273
+ The advisory half of the rule ``analysis._raise_no_replicated_subgroups``
2274
+ enforces at compute time. Xbar and S summarise a subgroup, so charting
2275
+ the response needs cells with more than one observation in them; on
2276
+ ADS 2 there are none and ``execute()`` refuses.
2277
+
2278
+ This is deliberately *not* the same question as :attr:`valid_charts`,
2279
+ which answers at the chart-family level and stays true on ADS 2 —
2280
+ ``execute(chart='Xbar', value='R6', by=[...])`` and
2281
+ ``execute(chart='Xbar', by=[time])`` are both legal there, because a
2282
+ residual and a time-aggregated subgroup do have replication. Conflating
2283
+ the two questions is what let ``why_not('Xbar')`` answer "IS available"
2284
+ about a call that then raised.
2285
+ """
2286
+ if base_chart not in ('Xbar', 'S'):
2287
+ return None
2288
+ if self._plan.has_replication != 'none':
2289
+ return None
2290
+
2291
+ factors = self._spec.rsg_vars_list
2292
+ pool_hint = f"by=['{factors[0]}']" if factors else 'by=[<factor>]'
2293
+ return (
2294
+ f"'{base_chart}' cannot chart the response here: every factor x time cell holds "
2295
+ f'a single observation, so there is no within-subgroup variation to summarise '
2296
+ f'(ADS {self.analytical_design_state.sds}, no replication).\n'
2297
+ f'Available instead:\n'
2298
+ f" • chart='X' — the response as individuals\n"
2299
+ f' • a coarser subgroup that pools those single observations — e.g. '
2300
+ f'{pool_hint}\n'
2301
+ f" • a residual — e.g. value='R6', {pool_hint}"
2302
+ )
2303
+
2304
+ def _residual_pair_problem(self, base_chart: str, value: str) -> str | None:
2305
+ """Why ``(base_chart, value)`` can't be charted — or ``None`` if it can.
2306
+
2307
+ Single source of truth for the chart x residual rule. ``_validate_execute_request``
2308
+ raises on it; ``why_not`` explains it. They cannot disagree because there is one rule.
2309
+
2310
+ Three regimes:
2311
+
2312
+ - ``Histogram`` — accepts any residual (it plots a distribution, not a control chart).
2313
+ - ``mR`` — accepts any residual, standalone or as a companion. A moving range is
2314
+ computed from consecutive values of whatever series is charted, so it is defined
2315
+ for a residual exactly as it is for the response.
2316
+ - ``Xbar`` / ``S`` / ``X`` — governed by :attr:`residual_charts`, which varies by ADS.
2317
+
2318
+ Neither ``Histogram`` nor ``mR`` appears in :attr:`residual_charts`, so that
2319
+ attribute is *not* the whole rule — a caller testing ``(chart, value) in
2320
+ residual_charts`` alone will wrongly reject both.
2321
+ """
2322
+ base_residual = _base_residual_code(value)
2323
+
2324
+ if base_chart in ('Histogram', 'mR'):
2325
+ return None
2326
+
2327
+ if (base_chart, base_residual) not in self.residual_charts:
2328
+ valid = [c for c, v in self.residual_charts if v == base_residual]
2329
+ return (
2330
+ f"'{base_chart}' with value='{value}' is not valid for "
2331
+ f'ADS {self.analytical_design_state.sds}.\n'
2332
+ f'Valid charts for {base_residual}: {", ".join(valid) if valid else "None"}'
2333
+ )
2334
+ return None
2335
+
2336
+ def _validate_recentered(self, value: str | None) -> None:
2337
+ """Validate recentered=True requirements."""
2338
+ needs_residuals = self._spec.has_grouping and self._spec.has_time
2339
+ if not needs_residuals:
2340
+ raise ValidationError(
2341
+ 'recentered=True requires VAS decomposition (factors + time). '
2342
+ 'Recentered residuals (RCR) reconstruct values relative to '
2343
+ 'factor and time means, which require both to be specified.'
2344
+ )
2345
+ recenterable = set(RESIDUAL_CODES)
2346
+ if value is None:
2347
+ raise ValidationError('recentered=True requires a residual value (R1-R6), got value=None')
2348
+ if value.upper() not in recenterable:
2349
+ raise ValidationError(f"recentered=True requires a residual value (R1-R6), got '{value}'")
2350
+
2351
+ def _validate_phased(self, base_chart: str, by_validated: list[str] | None) -> None:
2352
+ """Validate phased=True requirements."""
2353
+ if base_chart not in ('X', 'mR'):
2354
+ raise ValidationError(f"phased=True is only valid for X or mR charts, got '{base_chart}'.")
2355
+ if by_validated is None:
2356
+ raise ValidationError('phased=True requires an explicit by= parameter.')
2357
+ if not self._spec.rsg_vars_list:
2358
+ raise ValidationError(
2359
+ 'phased=True requires factors to define phases (rsg_key). '
2360
+ 'This study has no factors; phased limits do not apply.'
2361
+ )
2362
+ if len(by_validated) > 0:
2363
+ all_factors = set(self._spec.rsg_vars_list)
2364
+ by_set = set(by_validated)
2365
+ if not (all_factors - by_set):
2366
+ raise ValidationError(
2367
+ 'phased=True requires collapsed factors to define phases. '
2368
+ f'by={list(by_validated)} includes all factors; '
2369
+ 'no factors remain to create phase boundaries. '
2370
+ 'Remove factors from by= or set phased=False.'
2371
+ )
2372
+
2373
+ def _parse_chart_request(self, chart: str) -> str:
2374
+ """
2375
+ Parse chart string to validate base chart type.
2376
+
2377
+ Parameters
2378
+ ----------
2379
+ chart : str
2380
+ Chart request string. Only accepts base charts: 'Xbar', 'S', 'X', 'mR'
2381
+
2382
+ Returns
2383
+ -------
2384
+ str
2385
+ Validated base chart type
2386
+
2387
+ Raises
2388
+ ------
2389
+ ValidationError
2390
+ For invalid chart types or old residual chart syntax
2391
+ """
2392
+ if not chart or not isinstance(chart, str):
2393
+ raise ValidationError('Chart name must be a non-empty string')
2394
+
2395
+ # Normalize case (e.g. "x" -> "X", "XBAR" -> "Xbar")
2396
+ chart = normalize_chart_name(chart)
2397
+
2398
+ # Base chart - valid
2399
+ if chart in VALID_BASE_CHARTS:
2400
+ return chart
2401
+
2402
+ # R1-R6 without base chart
2403
+ if re.match(r'^R\d+$', chart) or re.match(r'^RCR\d+$', chart):
2404
+ raise ValidationError(
2405
+ f"'{chart}' is a residual identifier, not a chart type.\n"
2406
+ f"Use: study.execute(chart='Xbar', value='{chart}')\n"
2407
+ f"Or: study.execute(chart='X', value='{chart}')"
2408
+ )
2409
+
2410
+ # Alias without base chart.
2411
+ #
2412
+ # Must precede the old-syntax branch below. Every alias but 'noise' contains an
2413
+ # underscore, so testing '_' first sent them all to _raise_old_syntax_error,
2414
+ # which finds no base chart in the trailing word ('within_cell' -> 'cell') and
2415
+ # falls through to the generic "Invalid chart name". Four of the five original
2416
+ # aliases could never produce this message.
2417
+ if chart in RESIDUAL_ALIASES:
2418
+ residual_id = RESIDUAL_ALIASES[chart]
2419
+ raise ValidationError(
2420
+ f"'{chart}' is a residual alias for {residual_id} "
2421
+ f'({RESIDUAL_LABELS[residual_id]}), not a chart type.\n'
2422
+ f"Use: study.execute(chart='Xbar', value='{residual_id}')\n"
2423
+ f"Or: study.execute(chart='X', value='{residual_id}')"
2424
+ )
2425
+
2426
+ # Detect old residual chart syntax and provide migration guidance
2427
+ if '_' in chart:
2428
+ # Old syntax: R5_Xbar, noise_Xbar, rc_R5_Xbar, RCR5_Xbar
2429
+ self._raise_old_syntax_error(chart)
2430
+
2431
+ raise ValidationError(
2432
+ f"Unknown chart '{chart}'. Valid chart types: {', '.join(sorted(VALID_BASE_CHARTS))}."
2433
+ f'{suggest_chart_name(chart)}'
2434
+ )
2435
+
2436
+ def _raise_old_syntax_error(self, chart: str) -> None:
2437
+ """Raise helpful error for old residual chart syntax."""
2438
+ # Strip rc_ prefix if present
2439
+ working = chart
2440
+ recentered = False
2441
+ if working.startswith('rc_'):
2442
+ recentered = True
2443
+ working = working[3:]
2444
+
2445
+ # Try to parse residual and base chart
2446
+ if '_' in working:
2447
+ residual_part, base_chart = working.rsplit('_', 1)
2448
+
2449
+ # Check for RCR prefix
2450
+ if residual_part.startswith('RCR'):
2451
+ residual_id = f'R{residual_part[3:]}'
2452
+ recentered = True
2453
+ elif residual_part.startswith('R') and residual_part[1:].isdigit():
2454
+ residual_id = residual_part
2455
+ elif residual_part in RESIDUAL_ALIASES:
2456
+ residual_id = RESIDUAL_ALIASES[residual_part]
2457
+ else:
2458
+ residual_id = residual_part
2459
+
2460
+ if base_chart in VALID_BASE_CHARTS:
2461
+ recentered_hint = ', recentered=True' if recentered else ''
2462
+ raise ValidationError(
2463
+ f"Old residual chart syntax '{chart}' is no longer supported.\n"
2464
+ f"Use: study.execute(chart='{base_chart}', value='{residual_id}'{recentered_hint})"
2465
+ )
2466
+
2467
+ raise ValidationError(
2468
+ f"Invalid chart name '{chart}'. Valid chart types: {', '.join(sorted(VALID_BASE_CHARTS))}"
2469
+ )
2470
+
2471
+ def _validate_by_parameter( # noqa: C901
2472
+ self, by: list[str] | None, base_chart: str
2473
+ ) -> list[str] | None:
2474
+ """
2475
+ Validate and normalize the `by` parameter.
2476
+
2477
+ Parameters
2478
+ ----------
2479
+ by : list[str] | None
2480
+ User-specified by parameter
2481
+ base_chart : str
2482
+ Base chart type being requested (Xbar, S, X, mR)
2483
+
2484
+ Returns
2485
+ -------
2486
+ list[str] | None
2487
+ Normalized by parameter
2488
+
2489
+ Raises
2490
+ ------
2491
+ ValidationError
2492
+ If by contains factors not in rsg_vars
2493
+ If by=None for X/mR with factors (must be explicit)
2494
+ If by contains time variable for X/mR charts
2495
+ """
2496
+ factors = self._spec.rsg_vars_list
2497
+ time_var = self._spec.time_var
2498
+ is_time_series_chart = base_chart in ('X', 'mR')
2499
+
2500
+ # No factors case: by=None is fine, by=[] is also fine
2501
+ if not factors:
2502
+ if by is not None and by != []:
2503
+ raise ValidationError(
2504
+ f'Invalid by={by}. No factors defined in this study. Use by=None or by=[] for single stream.'
2505
+ )
2506
+ return [] # Normalize to empty list for no-factor case
2507
+
2508
+ # Has factors case
2509
+ if by is None:
2510
+ if is_time_series_chart:
2511
+ # X/mR with factors requires explicit by
2512
+ factor_str = ', '.join(f"'{f}'" for f in factors)
2513
+ raise ValidationError(
2514
+ f"X/mR charts with factors require explicit 'by' parameter.\n"
2515
+ f'Specify how to stratify:\n'
2516
+ f' by=[{factor_str}] for {self._get_factor_combinations()} charts (one per factor combination)\n'
2517
+ f" by=['{factors[0]}'] for fewer charts (stratify by single factor)\n"
2518
+ f' by=[] for single overall chart'
2519
+ )
2520
+ # Xbar/S: by=None means full rsg_key (current behavior)
2521
+ return None
2522
+
2523
+ # Normalize string to list
2524
+ if isinstance(by, str):
2525
+ by = [by]
2526
+
2527
+ # Validate by is subset of valid dimensions
2528
+ # - X/mR: factors only (time is x-axis)
2529
+ # - Xbar/S: factors + time (cell_key dimensions)
2530
+ by_set = set(by)
2531
+ factor_set = set(factors)
2532
+ invalid = by_set - factor_set
2533
+
2534
+ if invalid and time_var and time_var in invalid: # noqa: SIM102
2535
+ # Check if user tried to use time
2536
+ if is_time_series_chart:
2537
+ raise ValidationError(
2538
+ f"Cannot use time variable '{time_var}' in by for {base_chart} charts. "
2539
+ f'Time is the x-axis for {base_chart} charts, not a stratification dimension. '
2540
+ f'Valid by dimensions: {sorted(factors)}'
2541
+ )
2542
+ # For Xbar/S, time in by means group by time (one point per time)
2543
+ # This is valid - remove from invalid set
2544
+ invalid = invalid - {time_var}
2545
+
2546
+ if invalid:
2547
+ # Build valid dimensions list based on chart type
2548
+ valid_dims = sorted(factors)
2549
+ if time_var and not is_time_series_chart:
2550
+ valid_dims = sorted(factors + [time_var])
2551
+
2552
+ # Build helpful error message with suggestions
2553
+ suggestions = []
2554
+ first_invalid = None
2555
+ first_suggestion = None
2556
+
2557
+ for inv in sorted(invalid):
2558
+ # Check for case-insensitive match first
2559
+ case_match = next((v for v in valid_dims if v.lower() == inv.lower()), None)
2560
+ if case_match:
2561
+ suggestions.append(
2562
+ f"'{inv}' is not a valid by variable.\nDid you mean '{case_match}'? (names are case-sensitive)"
2563
+ )
2564
+ if first_invalid is None:
2565
+ first_invalid = inv
2566
+ first_suggestion = case_match
2567
+ else:
2568
+ # Try fuzzy matching
2569
+ close = difflib.get_close_matches(inv, valid_dims, n=1, cutoff=0.6)
2570
+ if close:
2571
+ suggestions.append(f"'{inv}' is not a valid by variable.\nDid you mean '{close[0]}'?")
2572
+ if first_invalid is None:
2573
+ first_invalid = inv
2574
+ first_suggestion = close[0]
2575
+ else:
2576
+ suggestions.append(f"'{inv}' is not a valid by variable.")
2577
+ if first_invalid is None:
2578
+ first_invalid = inv
2579
+
2580
+ msg = '\n'.join(suggestions)
2581
+ msg += f'\nValid: {", ".join(valid_dims)}'
2582
+ raise FactorNotFoundError(msg, factor=first_invalid, suggestion=first_suggestion, available=valid_dims)
2583
+
2584
+ # Deduplicate while preserving order (first occurrence wins)
2585
+ seen = set()
2586
+ deduped = []
2587
+ for v in by:
2588
+ if v not in seen:
2589
+ seen.add(v)
2590
+ deduped.append(v)
2591
+ if len(deduped) < len(by):
2592
+ import warnings
2593
+
2594
+ warnings.warn(
2595
+ f'Duplicate values in by={list(by)} were removed. Using by={deduped}.',
2596
+ UserWarning,
2597
+ stacklevel=4,
2598
+ )
2599
+ by = deduped
2600
+
2601
+ return list(by)
2602
+
2603
+ @staticmethod
2604
+ def _validate_n_sigma_n_mode(n_sigma: float, n_mode: str, base_chart: str) -> None:
2605
+ """Validate n_sigma and n_mode parameters for execute()."""
2606
+ from .exceptions import ValidationError
2607
+
2608
+ if (n_sigma != 3.0 or n_mode != 'actual') and base_chart not in ('Xbar', 'S'):
2609
+ raise ValidationError(f"n_sigma and n_mode are only supported for Xbar/S charts, got '{base_chart}'.")
2610
+
2611
+ if base_chart in ('Xbar', 'S'):
2612
+ if not (isinstance(n_sigma, (int, float)) and math.isfinite(n_sigma) and n_sigma > 0):
2613
+ raise ValidationError(f'n_sigma must be a finite number > 0; got {n_sigma!r}')
2614
+ if n_mode not in ('actual', 'average'):
2615
+ raise ValidationError(f"n_mode must be 'actual' or 'average'; got {n_mode!r}")
2616
+
2617
+ def _get_factor_combinations(self) -> int:
2618
+ """Get count of unique factor combinations in the dataset."""
2619
+ if not self._spec.rsg_vars:
2620
+ return 1
2621
+ return self._ads.analysis_dataset[self._spec.rsg_var_name].nunique()
2622
+
2623
+ def _resolve_value_column(self, value: str | None, recentered: bool) -> str:
2624
+ """
2625
+ Resolve the value parameter to a column name.
2626
+
2627
+ Parameters
2628
+ ----------
2629
+ value : str | None
2630
+ User-specified value ('response', 'R1', 'R2', etc.) or None
2631
+ recentered : bool
2632
+ Whether to use recentered residuals (RCR columns)
2633
+
2634
+ Returns
2635
+ -------
2636
+ str
2637
+ Column name to chart (response_var, R1-R5, or RCR1-RCR5)
2638
+
2639
+ Raises
2640
+ ------
2641
+ ValidationError
2642
+ If value specifies an unavailable residual
2643
+ """
2644
+ if value is None or value.lower() == 'response':
2645
+ return self._spec.response_var
2646
+
2647
+ # Parse residual specification
2648
+ value_upper = value.upper()
2649
+ if value_upper.startswith('RCR'):
2650
+ # Already recentered format
2651
+ col_name = value_upper
2652
+ elif value_upper.startswith('R') and len(value_upper) >= 2 and value_upper[1:].isdigit():
2653
+ residual_num = value_upper[1:]
2654
+ prefix = 'RCR' if recentered else 'R'
2655
+ col_name = f'{prefix}{residual_num}'
2656
+ else:
2657
+ raise ValidationError(
2658
+ f"Invalid value '{value}'. "
2659
+ f"Valid options: 'response', 'R1', 'R2', 'R3', 'R4', 'R5', 'R6' "
2660
+ f"(or 'RCR1'-'RCR5' for recentered, 'R6' with recentered=True)."
2661
+ )
2662
+
2663
+ # Validate column exists in analysis dataset
2664
+ if col_name not in self._ads.analysis_dataset.columns:
2665
+ available = [
2666
+ c for c in self._ads.analysis_dataset.columns if c.startswith('R') and len(c) == 2 and c[1].isdigit()
2667
+ ]
2668
+ raise ValidationError(
2669
+ f"Residual column '{col_name}' not available for ADS {self.analytical_design_state.sds}. "
2670
+ f'Available: {available}'
2671
+ )
2672
+
2673
+ return col_name
2674
+
2675
+ # =========================================================================
2676
+ # Display Methods
2677
+ # =========================================================================
2678
+
2679
+ def __repr__(self) -> str:
2680
+ """
2681
+ Concise study summary.
2682
+
2683
+ Shows formulation, design-state lineage, and available charts in
2684
+ minimal format. Use ``study.design()`` for the full lineage
2685
+ report and ``study.support`` for the chart availability DataFrame.
2686
+ """
2687
+ # 1-line formulation summary
2688
+ factors_str = ', '.join(self.factors) if self.factors else 'None'
2689
+ time_str = self.time or 'None'
2690
+
2691
+ ads_sds = self.analytical_design_state.sds
2692
+ ods_sds = self.observed_design_state.sds
2693
+ sds_display = f'ads={ads_sds}' if ads_sds == ods_sds else f'ods={ods_sds}, ads={ads_sds}'
2694
+ lines = [
2695
+ f"Study(response='{self.response}', factors=[{factors_str}], time='{time_str}', {sds_display})",
2696
+ ]
2697
+ if self.unit_of_analysis:
2698
+ lines.append(f' Unit of analysis: {self.unit_of_analysis}')
2699
+ valid_str = ', '.join(self.valid_charts) if self.valid_charts else 'None'
2700
+ rec_str = self.recommended_chart or 'None'
2701
+ lines.append(f' Valid: {valid_str} | Recommended: {rec_str}')
2702
+ if self.residuals:
2703
+ lines.append(f' Residuals: {", ".join(self.residuals)}')
2704
+ lines.append(' → study.execute() or study.support for details')
2705
+
2706
+ return '\n'.join(lines)
2707
+
2708
+ def _repr_html_(self) -> str:
2709
+ """HTML representation for Jupyter notebooks."""
2710
+ factors_str = ', '.join(self.factors) if self.factors else 'None'
2711
+ time_str = self.time or 'None'
2712
+
2713
+ residuals = self.residuals
2714
+ residual_html = f'<br><strong>Residuals:</strong> {", ".join(residuals)}' if residuals else ''
2715
+ uoa_html = f'<br><strong>Unit of analysis:</strong> {self.unit_of_analysis}' if self.unit_of_analysis else ''
2716
+
2717
+ ads_sds = self.analytical_design_state.sds
2718
+ ods_sds = self.observed_design_state.sds
2719
+ sds_display = f'sds={ads_sds}' if ads_sds == ods_sds else f'ods={ods_sds}, ads={ads_sds}'
2720
+
2721
+ style = 'font-family: monospace; padding: 8px; border: 1px solid #ccc; background: #f9f9f9'
2722
+ valid_charts_str = ', '.join(self.valid_charts) if self.valid_charts else 'None'
2723
+ rec_str = self.recommended_chart or 'None'
2724
+ html = f"""
2725
+ <div style="{style}">
2726
+ <code>Study(response='{self.response}', factors=[{factors_str}], time='{time_str}', {sds_display})</code>
2727
+ {uoa_html}
2728
+ <br><strong>Valid:</strong> {valid_charts_str} | <strong>Recommended:</strong> {rec_str}
2729
+ {residual_html}
2730
+ <br><em>→ study.execute() or study.support for details</em>
2731
+ </div>
2732
+ """
2733
+ return html