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.
- processbehavior/__init__.py +133 -0
- processbehavior/analysis.py +2944 -0
- processbehavior/analysis_dataset.py +361 -0
- processbehavior/analysis_result.py +1276 -0
- processbehavior/calibration.py +70 -0
- processbehavior/capability.py +720 -0
- processbehavior/data_preparation.py +792 -0
- processbehavior/datasets/__init__.py +37 -0
- processbehavior/datasets/data/coffee_shop_demo_long.csv +385 -0
- processbehavior/datasets/loaders.py +66 -0
- processbehavior/datasets/synthetic.py +1711 -0
- processbehavior/derivations.py +602 -0
- processbehavior/effects_calculator.py +603 -0
- processbehavior/exceptions.py +216 -0
- processbehavior/exporters/__init__.py +5 -0
- processbehavior/exporters/excel_exporter.py +708 -0
- processbehavior/focused_result.py +329 -0
- processbehavior/formulation_spec.py +144 -0
- processbehavior/loss_function.py +517 -0
- processbehavior/maximum_information.py +285 -0
- processbehavior/plotting/__init__.py +46 -0
- processbehavior/plotting/_math.py +121 -0
- processbehavior/plotting/capability_chart.py +858 -0
- processbehavior/plotting/contracts.py +176 -0
- processbehavior/plotting/control_chart.py +206 -0
- processbehavior/plotting/effects_charts.py +640 -0
- processbehavior/plotting/lane_boundaries.py +78 -0
- processbehavior/plotting/limits.py +157 -0
- processbehavior/plotting/maximum_information_chart.py +395 -0
- processbehavior/plotting/plotter.py +1469 -0
- processbehavior/plotting/renderers.py +660 -0
- processbehavior/plotting/report.py +211 -0
- processbehavior/plotting/residuals.py +282 -0
- processbehavior/plotting/run_rules_viz.py +150 -0
- processbehavior/plotting/stats_box.py +183 -0
- processbehavior/plotting/themes.py +544 -0
- processbehavior/plotting/x_axis_layout.py +474 -0
- processbehavior/plotting/zones.py +127 -0
- processbehavior/process_behavior.py +1290 -0
- processbehavior/py.typed +0 -0
- processbehavior/residual_calculator.py +572 -0
- processbehavior/result_signals.py +149 -0
- processbehavior/result_tabular.py +296 -0
- processbehavior/sds_detector.py +1432 -0
- processbehavior/signals/__init__.py +14 -0
- processbehavior/signals/config.py +435 -0
- processbehavior/signals/detector.py +328 -0
- processbehavior/signals/detectors.py +368 -0
- processbehavior/signals/result.py +232 -0
- processbehavior/spc_constants.py +692 -0
- processbehavior/study.py +2733 -0
- processbehavior/types.py +154 -0
- processbehavior-0.2.0.dist-info/METADATA +279 -0
- processbehavior-0.2.0.dist-info/RECORD +56 -0
- processbehavior-0.2.0.dist-info/WHEEL +4 -0
- processbehavior-0.2.0.dist-info/licenses/LICENSE +201 -0
processbehavior/study.py
ADDED
|
@@ -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
|