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
|
@@ -0,0 +1,1276 @@
|
|
|
1
|
+
"""
|
|
2
|
+
AnalysisResult - Unified container for all analysis outputs.
|
|
3
|
+
|
|
4
|
+
This module provides a comprehensive result object that makes all analysis data
|
|
5
|
+
easily accessible in one place:
|
|
6
|
+
- Chart data (Xbar, S, X, mR) with chart type as primary key
|
|
7
|
+
- Stratified chart support with strata property and focus() for drill-down
|
|
8
|
+
- Residuals (R1-R5)
|
|
9
|
+
- Effects (main effects, interactions)
|
|
10
|
+
- Summary metadata (design-state lineage, statistics, capabilities)
|
|
11
|
+
|
|
12
|
+
Chart Structure
|
|
13
|
+
---------------
|
|
14
|
+
Charts are always keyed by chart type (e.g., 'Xbar', 'S', 'X', 'mR').
|
|
15
|
+
X and mR charts are bundled together, similar to Xbar and S.
|
|
16
|
+
|
|
17
|
+
For stratified X/mR charts, the structure includes:
|
|
18
|
+
- Combined DataFrame with all strata (rsg column identifies them)
|
|
19
|
+
- Statistics nested by stratum: {'Machine1': {...}, 'Machine2': {...}}
|
|
20
|
+
- 'strata' list for discovering available subgroups
|
|
21
|
+
|
|
22
|
+
Usage
|
|
23
|
+
-----
|
|
24
|
+
Basic access::
|
|
25
|
+
|
|
26
|
+
result = study.execute()
|
|
27
|
+
xbar_data = result.get_chart('Xbar')
|
|
28
|
+
stats = result.get_statistics('Xbar')
|
|
29
|
+
|
|
30
|
+
Stratified chart drill-down::
|
|
31
|
+
|
|
32
|
+
result.strata # ['Machine1_F2_1', 'Machine2_F2_2', ...]
|
|
33
|
+
result.is_stratified # True
|
|
34
|
+
|
|
35
|
+
focused = result.focus('Machine1_F2_1')
|
|
36
|
+
focused.plot()
|
|
37
|
+
focused.to_excel('machine1.xlsx')
|
|
38
|
+
|
|
39
|
+
Access residuals and effects::
|
|
40
|
+
|
|
41
|
+
residuals = result.residuals # DataFrame with R1-R5
|
|
42
|
+
main_effects = result.effects
|
|
43
|
+
interactions = result.interactions
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
from __future__ import annotations
|
|
47
|
+
|
|
48
|
+
import logging
|
|
49
|
+
from typing import TYPE_CHECKING, Any, Literal
|
|
50
|
+
|
|
51
|
+
import pandas as pd
|
|
52
|
+
|
|
53
|
+
from .exceptions import ChartNotAvailableError, ValidationError
|
|
54
|
+
from .sds_detector import SDSRegistry
|
|
55
|
+
from .spc_constants import ALL_RESIDUALS, REQUEST_RESIDUALS, STORED_RESIDUALS, normalize_chart_name
|
|
56
|
+
from .types import Charts
|
|
57
|
+
|
|
58
|
+
if TYPE_CHECKING:
|
|
59
|
+
from pathlib import Path
|
|
60
|
+
|
|
61
|
+
from .analysis_dataset import AnalysisDataSet
|
|
62
|
+
from .plotting.control_chart import ControlChartFigure
|
|
63
|
+
from .signals.result import SignalResult
|
|
64
|
+
|
|
65
|
+
logger = logging.getLogger(__name__)
|
|
66
|
+
|
|
67
|
+
# Built-in theme names (Literal for IDE autocomplete on .plot(theme=...)).
|
|
68
|
+
# Custom themes registered via ``register_theme`` work too — the bare
|
|
69
|
+
# ``str`` union on the parameter type allows them through.
|
|
70
|
+
ThemeName = Literal['processbehavior', 'ggplot', 'minimal', 'dark', 'publication']
|
|
71
|
+
|
|
72
|
+
# Standard SPC chart type names
|
|
73
|
+
STANDARD_CHART_NAMES = {'Xbar', 'S', 'X', 'mR'}
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class AnalysisResult:
|
|
77
|
+
"""
|
|
78
|
+
Comprehensive analysis result container.
|
|
79
|
+
|
|
80
|
+
This class unifies all analysis outputs into a single, easily accessible object.
|
|
81
|
+
It provides:
|
|
82
|
+
- Chart data and statistics (Xbar, S, X, mR)
|
|
83
|
+
- VAS residuals (R1-R5)
|
|
84
|
+
- Main effects and interactions
|
|
85
|
+
- Design-state lineage information (PDS / ODS / ADS)
|
|
86
|
+
- Summary metadata
|
|
87
|
+
- Stratified chart support with focus() for drill-down
|
|
88
|
+
|
|
89
|
+
Chart Structure
|
|
90
|
+
---------------
|
|
91
|
+
Charts are keyed by chart type (e.g., 'Xbar', 'S', 'X', 'mR'):
|
|
92
|
+
|
|
93
|
+
- For standard charts:
|
|
94
|
+
``{'Xbar': {'data': DataFrame, 'statistics': dict, 'metadata': dict}}``
|
|
95
|
+
|
|
96
|
+
- For stratified X/mR charts (multiple subgroups):
|
|
97
|
+
``{'X': {'data': DataFrame, 'statistics': {stratum: dict}, 'strata': list}}``
|
|
98
|
+
|
|
99
|
+
X and mR charts are always bundled together, similar to Xbar and S.
|
|
100
|
+
|
|
101
|
+
Attributes
|
|
102
|
+
----------
|
|
103
|
+
charts : dict
|
|
104
|
+
Dictionary of chart data keyed by chart type.
|
|
105
|
+
dataset : pd.DataFrame
|
|
106
|
+
Full analysis dataset with all calculations.
|
|
107
|
+
residuals : pd.DataFrame or None
|
|
108
|
+
VAS residuals (R1-R5) if calculated.
|
|
109
|
+
effects : dict or None
|
|
110
|
+
Main effects if calculated.
|
|
111
|
+
interactions : dict or None
|
|
112
|
+
Interaction effects if calculated.
|
|
113
|
+
summary : dict
|
|
114
|
+
Comprehensive metadata about the analysis.
|
|
115
|
+
sds : int
|
|
116
|
+
Sampling Design State (0-6).
|
|
117
|
+
sds_info : dict
|
|
118
|
+
Detailed SDS characteristics.
|
|
119
|
+
strata : list[str]
|
|
120
|
+
List of subgroup names for stratified charts (empty if not stratified).
|
|
121
|
+
is_stratified : bool
|
|
122
|
+
True if result contains stratified charts with multiple subgroups.
|
|
123
|
+
|
|
124
|
+
Examples
|
|
125
|
+
--------
|
|
126
|
+
Basic usage:
|
|
127
|
+
|
|
128
|
+
>>> result = study.execute()
|
|
129
|
+
>>> xbar = result.get_chart('Xbar')
|
|
130
|
+
>>> print(result.summary)
|
|
131
|
+
|
|
132
|
+
Stratified chart access:
|
|
133
|
+
|
|
134
|
+
>>> result.strata # ['Machine1_F2_1', 'Machine2_F2_2', ...]
|
|
135
|
+
>>> focused = result.focus('Machine1_F2_1')
|
|
136
|
+
>>> focused.plot()
|
|
137
|
+
>>> focused.to_excel('machine1.xlsx')
|
|
138
|
+
"""
|
|
139
|
+
|
|
140
|
+
def __init__(
|
|
141
|
+
self,
|
|
142
|
+
charts: Charts,
|
|
143
|
+
analysis_dataset_obj: AnalysisDataSet,
|
|
144
|
+
analysis_type: str | None = None,
|
|
145
|
+
request_dataset: pd.DataFrame | None = None,
|
|
146
|
+
):
|
|
147
|
+
"""
|
|
148
|
+
Initialize AnalysisResult from chart data and AnalysisDataSet.
|
|
149
|
+
|
|
150
|
+
Parameters
|
|
151
|
+
----------
|
|
152
|
+
charts : Charts (dict[str, ChartPayload])
|
|
153
|
+
Chart-name → :class:`ChartPayload` map. Each payload carries
|
|
154
|
+
``data`` (DataFrame), ``statistics`` (flat or by-stratum),
|
|
155
|
+
``metadata``, and optional ``strata``.
|
|
156
|
+
analysis_dataset_obj : AnalysisDataSet
|
|
157
|
+
The underlying AnalysisDataSet with all calculations
|
|
158
|
+
analysis_type : str, optional
|
|
159
|
+
The executed chart type ('Xbar', 'S', 'X', 'mR').
|
|
160
|
+
Passed from Analysis at execute() time so result.summary
|
|
161
|
+
reports the executed chart, not the recommended one.
|
|
162
|
+
request_dataset : pd.DataFrame, optional
|
|
163
|
+
This request's own frame, carrying its request residual (R6/RCR6).
|
|
164
|
+
Only a request-residual execute() passes one; every other result
|
|
165
|
+
reads the shared study-level frame.
|
|
166
|
+
"""
|
|
167
|
+
# Store chart data
|
|
168
|
+
self.charts: Charts = charts
|
|
169
|
+
|
|
170
|
+
# Backing frame: the shared study-level frame, or — for a request
|
|
171
|
+
# residual (R6/RCR6) — this request's own augmented copy. Public access
|
|
172
|
+
# goes through the `dataset` property, which hands out a copy — see there.
|
|
173
|
+
self._ads = analysis_dataset_obj
|
|
174
|
+
self._dataset = request_dataset if request_dataset is not None else analysis_dataset_obj.analysis_dataset
|
|
175
|
+
|
|
176
|
+
# Store the executed analysis type (passed from Analysis)
|
|
177
|
+
self._analysis_type = analysis_type
|
|
178
|
+
|
|
179
|
+
# Design state information
|
|
180
|
+
self.observed_sds: int = analysis_dataset_obj.observed_design_state
|
|
181
|
+
self.analytical_sds: int = analysis_dataset_obj.analytical_design_state.sds
|
|
182
|
+
self.observed_sds_info: dict = analysis_dataset_obj.raw_sds_characteristics
|
|
183
|
+
self.analytical_sds_info: dict = SDSRegistry().get_sds_characteristics(self.analytical_sds)
|
|
184
|
+
|
|
185
|
+
# Extract residuals if calculated
|
|
186
|
+
self._residuals = None
|
|
187
|
+
if analysis_dataset_obj.has_vas_residuals:
|
|
188
|
+
# Stored residuals only — request residuals (R6) are by=-dependent and
|
|
189
|
+
# belong to a single execute() result, not to this study-level snapshot.
|
|
190
|
+
residual_cols = list(STORED_RESIDUALS)
|
|
191
|
+
available_cols = [c for c in residual_cols if c in self._dataset.columns]
|
|
192
|
+
if available_cols:
|
|
193
|
+
self._residuals = self._dataset[available_cols].copy()
|
|
194
|
+
|
|
195
|
+
# Extract effects and interactions
|
|
196
|
+
self._effects = analysis_dataset_obj.effects if analysis_dataset_obj.effects else None
|
|
197
|
+
self._interactions = analysis_dataset_obj.interactions if analysis_dataset_obj.interactions else None
|
|
198
|
+
|
|
199
|
+
# Build comprehensive summary
|
|
200
|
+
self._summary = self._build_summary()
|
|
201
|
+
|
|
202
|
+
@property
|
|
203
|
+
def dataset(self) -> pd.DataFrame:
|
|
204
|
+
"""
|
|
205
|
+
Full analysis dataset with means and residuals.
|
|
206
|
+
|
|
207
|
+
Returns a fresh copy on every access, matching ``Study.dataset``.
|
|
208
|
+
The frame behind a plain result is shared by the study and every other
|
|
209
|
+
plain result, so handing out the live frame would let one caller mutate
|
|
210
|
+
another's data. A request-residual result (R6/RCR6) is backed by its
|
|
211
|
+
own frame carrying that request's residual columns.
|
|
212
|
+
|
|
213
|
+
Returns
|
|
214
|
+
-------
|
|
215
|
+
pd.DataFrame
|
|
216
|
+
Copy of the analysis dataset.
|
|
217
|
+
"""
|
|
218
|
+
return self._dataset.copy()
|
|
219
|
+
|
|
220
|
+
def _build_summary(self) -> dict:
|
|
221
|
+
"""
|
|
222
|
+
Build comprehensive summary of analysis.
|
|
223
|
+
|
|
224
|
+
Returns
|
|
225
|
+
-------
|
|
226
|
+
dict
|
|
227
|
+
Summary with SDS info, capabilities, and statistics
|
|
228
|
+
"""
|
|
229
|
+
# Count total signals across all charts
|
|
230
|
+
n_signals = 0
|
|
231
|
+
|
|
232
|
+
# Handle case where charts might not be a dict
|
|
233
|
+
if not isinstance(self.charts, dict):
|
|
234
|
+
logger.warning(f"Charts is not a dict, it's a {type(self.charts)}")
|
|
235
|
+
chart_values = []
|
|
236
|
+
else:
|
|
237
|
+
chart_values = self.charts.values()
|
|
238
|
+
|
|
239
|
+
for chart_info in chart_values:
|
|
240
|
+
if 'data' in chart_info and 'beyond_limits' in chart_info['data'].columns:
|
|
241
|
+
n_signals += (chart_info['data']['beyond_limits'] != 0).sum()
|
|
242
|
+
|
|
243
|
+
summary = {
|
|
244
|
+
# SDS information
|
|
245
|
+
'observed_sds': self.observed_sds,
|
|
246
|
+
'analytical_sds': self.analytical_sds,
|
|
247
|
+
'analytical_sds_description': self.analytical_sds_info.get('description', 'Unknown'),
|
|
248
|
+
'analytical_sds_capabilities': self.analytical_sds_info.get('capabilities', []),
|
|
249
|
+
'replication_type': self.analytical_sds_info.get('replication_type', 'unknown'),
|
|
250
|
+
# Analysis configuration
|
|
251
|
+
'analysis_type': self._analysis_type,
|
|
252
|
+
'response_var': self._ads.spec.response_var,
|
|
253
|
+
'grouping_vars': self._ads.spec.rsg_vars,
|
|
254
|
+
'time_var': self._ads.spec.time_var,
|
|
255
|
+
# Data dimensions
|
|
256
|
+
'n_observations': len(self._dataset),
|
|
257
|
+
'n_charts': len(self.charts),
|
|
258
|
+
'chart_types': list(self.charts.keys()),
|
|
259
|
+
# Capabilities
|
|
260
|
+
'has_residuals': self.has_residuals,
|
|
261
|
+
'has_effects': self.has_effects,
|
|
262
|
+
'has_interactions': self.has_interactions,
|
|
263
|
+
'variance_decomposition': self.analytical_sds_info.get('variance_decomposition', False),
|
|
264
|
+
'interaction_analysis': self.analytical_sds_info.get('interaction_analysis', False),
|
|
265
|
+
# Signals
|
|
266
|
+
'n_signals_total': int(n_signals),
|
|
267
|
+
# Stratification
|
|
268
|
+
'is_stratified': self._is_stratified(),
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
return summary
|
|
272
|
+
|
|
273
|
+
def _is_stratified(self) -> bool:
|
|
274
|
+
"""
|
|
275
|
+
Check if this is a stratified analysis (separate charts per group).
|
|
276
|
+
|
|
277
|
+
Returns
|
|
278
|
+
-------
|
|
279
|
+
bool
|
|
280
|
+
True if any chart has a non-empty 'strata' key
|
|
281
|
+
"""
|
|
282
|
+
# Handle case where charts might not be a dict
|
|
283
|
+
if not isinstance(self.charts, dict):
|
|
284
|
+
return False
|
|
285
|
+
|
|
286
|
+
for chart_info in self.charts.values():
|
|
287
|
+
if isinstance(chart_info, dict) and 'strata' in chart_info and chart_info['strata']:
|
|
288
|
+
return True
|
|
289
|
+
return False
|
|
290
|
+
|
|
291
|
+
# =========================================================================
|
|
292
|
+
# Properties for easy access
|
|
293
|
+
# =========================================================================
|
|
294
|
+
|
|
295
|
+
@property
|
|
296
|
+
def residuals(self) -> pd.DataFrame:
|
|
297
|
+
"""
|
|
298
|
+
Get the **stored** VAS residuals (R1-R5) if calculated.
|
|
299
|
+
|
|
300
|
+
Deliberately excludes the **request** residuals (R6, RCR6): those are a function
|
|
301
|
+
of the ``execute()`` request's ``by=``, so there is no single study-level series
|
|
302
|
+
to put in this frame. Reach them with :meth:`get_residual` on a result that asked
|
|
303
|
+
for them.
|
|
304
|
+
|
|
305
|
+
Returns
|
|
306
|
+
-------
|
|
307
|
+
DataFrame
|
|
308
|
+
DataFrame with columns [R1, R2, R3, R4, R5] if residuals were
|
|
309
|
+
calculated, empty DataFrame with same schema otherwise
|
|
310
|
+
"""
|
|
311
|
+
if self._residuals is None:
|
|
312
|
+
return pd.DataFrame(columns=['R1', 'R2', 'R3', 'R4', 'R5'])
|
|
313
|
+
return self._residuals.copy()
|
|
314
|
+
|
|
315
|
+
@property
|
|
316
|
+
def effects(self) -> dict:
|
|
317
|
+
"""
|
|
318
|
+
Get main effects if calculated.
|
|
319
|
+
|
|
320
|
+
Returns a dict keyed by effect type rather than a single DataFrame.
|
|
321
|
+
This is intentional: each effect type has different semantics and
|
|
322
|
+
structure, and users typically work with one effect type at a time.
|
|
323
|
+
|
|
324
|
+
Keys are named after your own factor columns, so they vary by study.
|
|
325
|
+
For ``factors=['machine', 'shift']`` the dict holds:
|
|
326
|
+
|
|
327
|
+
- ``'machine'``, ``'shift'`` — per-factor main effects, one row per
|
|
328
|
+
level, in a ``Main_Effect`` column.
|
|
329
|
+
- ``'main_effect'`` — main effects of the composite rational subgroup,
|
|
330
|
+
one row per ``rsg`` value.
|
|
331
|
+
- ``'time'`` — time main effects, one row per time point, in a
|
|
332
|
+
``PT_ME`` column.
|
|
333
|
+
- ``'machine_MEs'``, ``'shift_MEs'`` — observation-level main-effect
|
|
334
|
+
scores (one row per observation, not per level).
|
|
335
|
+
- ``'factor_interaction_effects'`` — observation-level interaction
|
|
336
|
+
effects; present only when two or more factors were declared.
|
|
337
|
+
|
|
338
|
+
Because the keys carry your column names, a factor literally named
|
|
339
|
+
``time`` or ``main_effect`` collides with the library's own keys.
|
|
340
|
+
|
|
341
|
+
Returns
|
|
342
|
+
-------
|
|
343
|
+
dict[str, DataFrame]
|
|
344
|
+
Main effects keyed as described above. Empty dict if not
|
|
345
|
+
calculated (no factors, or a design state without effects).
|
|
346
|
+
|
|
347
|
+
Examples
|
|
348
|
+
--------
|
|
349
|
+
>>> effects = result.effects # doctest: +SKIP
|
|
350
|
+
>>> sorted(effects) # doctest: +SKIP
|
|
351
|
+
['factor_interaction_effects', 'machine', 'machine_MEs', 'main_effect',
|
|
352
|
+
'shift', 'shift_MEs', 'time']
|
|
353
|
+
>>> effects['machine'] # doctest: +SKIP
|
|
354
|
+
>>> effects['time'] # doctest: +SKIP
|
|
355
|
+
"""
|
|
356
|
+
return self._effects.copy() if self._effects else {}
|
|
357
|
+
|
|
358
|
+
@property
|
|
359
|
+
def interactions(self) -> dict:
|
|
360
|
+
"""
|
|
361
|
+
Get interaction effects if calculated.
|
|
362
|
+
|
|
363
|
+
Returns a dict keyed by interaction type rather than a single DataFrame.
|
|
364
|
+
This is intentional: interaction structures vary by SDS, and the dict
|
|
365
|
+
keys provide meaningful organization for different interaction terms.
|
|
366
|
+
|
|
367
|
+
Returns
|
|
368
|
+
-------
|
|
369
|
+
dict[str, DataFrame | Series]
|
|
370
|
+
Dictionary with interaction terms (structure varies by SDS).
|
|
371
|
+
Empty dict if not calculated.
|
|
372
|
+
|
|
373
|
+
Examples
|
|
374
|
+
--------
|
|
375
|
+
>>> interactions = result.interactions
|
|
376
|
+
>>> for name, data in interactions.items():
|
|
377
|
+
... print(f"{name}: {data.shape}")
|
|
378
|
+
"""
|
|
379
|
+
return self._interactions.copy() if self._interactions else {}
|
|
380
|
+
|
|
381
|
+
@property
|
|
382
|
+
def summary(self) -> dict:
|
|
383
|
+
"""
|
|
384
|
+
Get comprehensive summary of analysis.
|
|
385
|
+
|
|
386
|
+
Returns
|
|
387
|
+
-------
|
|
388
|
+
dict
|
|
389
|
+
Summary with SDS info, capabilities, dimensions, and statistics
|
|
390
|
+
"""
|
|
391
|
+
return self._summary.copy()
|
|
392
|
+
|
|
393
|
+
@property
|
|
394
|
+
def has_residuals(self) -> bool:
|
|
395
|
+
"""Check if VAS residuals were calculated."""
|
|
396
|
+
return self._residuals is not None
|
|
397
|
+
|
|
398
|
+
@property
|
|
399
|
+
def has_effects(self) -> bool:
|
|
400
|
+
"""Check if main effects were calculated."""
|
|
401
|
+
return self._effects is not None and len(self._effects) > 0
|
|
402
|
+
|
|
403
|
+
@property
|
|
404
|
+
def has_interactions(self) -> bool:
|
|
405
|
+
"""Check if interaction effects were calculated."""
|
|
406
|
+
return self._interactions is not None and len(self._interactions) > 0
|
|
407
|
+
|
|
408
|
+
@property
|
|
409
|
+
def all_charts(self) -> list[str]:
|
|
410
|
+
"""Get list of all available chart names."""
|
|
411
|
+
return list(self.charts.keys())
|
|
412
|
+
|
|
413
|
+
@property
|
|
414
|
+
def strata(self) -> list[str]:
|
|
415
|
+
"""
|
|
416
|
+
Get list of available subgroups focusable across every chart.
|
|
417
|
+
|
|
418
|
+
For stratified analysis, returns the order-preserving intersection
|
|
419
|
+
of every chart's strata list. A stratum present in one chart but
|
|
420
|
+
missing data in another (e.g. an X+mR pair where mR dropped a
|
|
421
|
+
single-observation cell) is excluded so that the returned values
|
|
422
|
+
are valid inputs to ``focus()`` for the full result.
|
|
423
|
+
|
|
424
|
+
Returns
|
|
425
|
+
-------
|
|
426
|
+
list[str]
|
|
427
|
+
List of stratum names focusable across all charts. Empty list
|
|
428
|
+
if no chart is stratified.
|
|
429
|
+
|
|
430
|
+
Examples
|
|
431
|
+
--------
|
|
432
|
+
>>> result = study.execute(chart='X', by=['factor 1'])
|
|
433
|
+
>>> result.strata
|
|
434
|
+
['level_1', 'level_2', ...]
|
|
435
|
+
|
|
436
|
+
>>> if result.strata:
|
|
437
|
+
... for stratum in result.strata:
|
|
438
|
+
... focused = result.focus(stratum)
|
|
439
|
+
... focused.plot()
|
|
440
|
+
"""
|
|
441
|
+
strata_lists = [
|
|
442
|
+
list(chart_info['strata'])
|
|
443
|
+
for chart_info in self.charts.values()
|
|
444
|
+
if 'strata' in chart_info and chart_info['strata']
|
|
445
|
+
]
|
|
446
|
+
if not strata_lists:
|
|
447
|
+
return []
|
|
448
|
+
if len(strata_lists) == 1:
|
|
449
|
+
return strata_lists[0]
|
|
450
|
+
common = set(strata_lists[0])
|
|
451
|
+
for sl in strata_lists[1:]:
|
|
452
|
+
common &= set(sl)
|
|
453
|
+
return [s for s in strata_lists[0] if s in common]
|
|
454
|
+
|
|
455
|
+
@property
|
|
456
|
+
def is_stratified(self) -> bool:
|
|
457
|
+
"""
|
|
458
|
+
Check if this result contains stratified charts.
|
|
459
|
+
|
|
460
|
+
Returns
|
|
461
|
+
-------
|
|
462
|
+
bool
|
|
463
|
+
True if charts have multiple strata, False otherwise.
|
|
464
|
+
"""
|
|
465
|
+
return len(self.strata) > 0
|
|
466
|
+
|
|
467
|
+
def focus(self, stratum: str) -> AnalysisResult:
|
|
468
|
+
"""
|
|
469
|
+
Return new AnalysisResult focused on a single stratum.
|
|
470
|
+
|
|
471
|
+
For stratified X/mR analysis, this allows drilling down to
|
|
472
|
+
a specific subgroup. The returned result is immutable - the
|
|
473
|
+
original result is unchanged.
|
|
474
|
+
|
|
475
|
+
Parameters
|
|
476
|
+
----------
|
|
477
|
+
stratum : str
|
|
478
|
+
Name of the stratum to focus on (from result.strata)
|
|
479
|
+
|
|
480
|
+
Returns
|
|
481
|
+
-------
|
|
482
|
+
AnalysisResult
|
|
483
|
+
New AnalysisResult containing only data for the specified stratum
|
|
484
|
+
|
|
485
|
+
Raises
|
|
486
|
+
------
|
|
487
|
+
ValidationError
|
|
488
|
+
If stratum is not in result.strata or focus produces empty data.
|
|
489
|
+
ProcessBehaviorError
|
|
490
|
+
If required stratification metadata/columns are inconsistent.
|
|
491
|
+
|
|
492
|
+
Examples
|
|
493
|
+
--------
|
|
494
|
+
>>> result = study.execute(chart='X', by=['factor 1'])
|
|
495
|
+
>>> result.strata
|
|
496
|
+
['level_1', 'level_2', ...]
|
|
497
|
+
>>> focused = result.focus('level_1')
|
|
498
|
+
>>> focused.plot()
|
|
499
|
+
|
|
500
|
+
>>> # Chaining works
|
|
501
|
+
>>> result.focus('level_1').plot()
|
|
502
|
+
"""
|
|
503
|
+
from .focused_result import focus_on
|
|
504
|
+
|
|
505
|
+
return focus_on(self, stratum)
|
|
506
|
+
|
|
507
|
+
# =========================================================================
|
|
508
|
+
# Convenience methods for accessing data
|
|
509
|
+
# =========================================================================
|
|
510
|
+
|
|
511
|
+
def _resolve_chart_name(self, name: str) -> str:
|
|
512
|
+
"""Resolve chart name with case-insensitive fallback for base charts."""
|
|
513
|
+
if name in self.charts:
|
|
514
|
+
return name
|
|
515
|
+
normalized = normalize_chart_name(name)
|
|
516
|
+
if normalized in self.charts:
|
|
517
|
+
return normalized
|
|
518
|
+
return name # Return as-is; caller raises the appropriate error
|
|
519
|
+
|
|
520
|
+
def get_chart(self, name: str) -> pd.DataFrame:
|
|
521
|
+
"""
|
|
522
|
+
Get chart data by name.
|
|
523
|
+
|
|
524
|
+
Parameters
|
|
525
|
+
----------
|
|
526
|
+
name : str
|
|
527
|
+
Chart name (e.g., 'Xbar', 'S', 'GroupA', 'all')
|
|
528
|
+
|
|
529
|
+
Returns
|
|
530
|
+
-------
|
|
531
|
+
DataFrame
|
|
532
|
+
Chart data
|
|
533
|
+
|
|
534
|
+
Raises
|
|
535
|
+
------
|
|
536
|
+
ChartNotAvailableError
|
|
537
|
+
If chart name not found in this result.
|
|
538
|
+
|
|
539
|
+
Examples
|
|
540
|
+
--------
|
|
541
|
+
>>> xbar = result.get_chart('Xbar')
|
|
542
|
+
>>> alice = result.get_chart('Alice') # For stratified X
|
|
543
|
+
"""
|
|
544
|
+
name = self._resolve_chart_name(name)
|
|
545
|
+
if name not in self.charts:
|
|
546
|
+
raise ChartNotAvailableError(
|
|
547
|
+
f"Chart '{name}' not found. Available charts: {self.all_charts}", chart=name, available=self.all_charts
|
|
548
|
+
)
|
|
549
|
+
return self.charts[name]['data'].copy()
|
|
550
|
+
|
|
551
|
+
def get_statistics(self, name: str) -> dict:
|
|
552
|
+
"""
|
|
553
|
+
Get chart statistics by name.
|
|
554
|
+
|
|
555
|
+
Parameters
|
|
556
|
+
----------
|
|
557
|
+
name : str
|
|
558
|
+
Chart name (e.g., 'Xbar', 'S', 'GroupA')
|
|
559
|
+
|
|
560
|
+
Returns
|
|
561
|
+
-------
|
|
562
|
+
dict
|
|
563
|
+
Statistics dictionary. Every chart guarantees the same four
|
|
564
|
+
unified keys: ``{'N', 'center', 'lpl', 'upl'}``.
|
|
565
|
+
|
|
566
|
+
``N`` is the subgroup size behind one plotted point. On Xbar/S
|
|
567
|
+
that is the cell size; on an X chart it is ``1`` (one
|
|
568
|
+
observation per point); on an mR chart it is ``2`` (the moving
|
|
569
|
+
window the range is taken over).
|
|
570
|
+
|
|
571
|
+
When the control limits or subgroup size vary across
|
|
572
|
+
subgroups (e.g. Xbar/S with ``n_mode='actual'`` on unbalanced
|
|
573
|
+
data, phased charts, or any chart whose limits aren't a
|
|
574
|
+
single scalar), ``N``, ``lpl``, and ``upl`` are ``None`` and
|
|
575
|
+
an optional ``'limits_vary': True`` flag is added so callers
|
|
576
|
+
can detect variable limits without a type check.
|
|
577
|
+
|
|
578
|
+
The Histogram chart also includes ``{'mean', 'std', 'n'}``
|
|
579
|
+
as additional fields (``mean`` is an alias for ``center``;
|
|
580
|
+
``std`` is the sample standard deviation; ``n`` is an alias
|
|
581
|
+
for ``N``); its ``'lpl'`` and ``'upl'`` are ``None`` because
|
|
582
|
+
a histogram has no control limits.
|
|
583
|
+
|
|
584
|
+
Raises
|
|
585
|
+
------
|
|
586
|
+
ChartNotAvailableError
|
|
587
|
+
If chart name not found in this result.
|
|
588
|
+
|
|
589
|
+
Examples
|
|
590
|
+
--------
|
|
591
|
+
>>> stats = result.get_statistics('Xbar')
|
|
592
|
+
>>> print(f"Center: {stats['center']}, UPL: {stats['upl']}")
|
|
593
|
+
"""
|
|
594
|
+
name = self._resolve_chart_name(name)
|
|
595
|
+
if name not in self.charts:
|
|
596
|
+
raise ChartNotAvailableError(
|
|
597
|
+
f"Chart '{name}' not found. Available charts: {self.all_charts}", chart=name, available=self.all_charts
|
|
598
|
+
)
|
|
599
|
+
return self.charts[name]['statistics'].copy()
|
|
600
|
+
|
|
601
|
+
def statistics(self, chart: str, stratum: str | None = None) -> dict:
|
|
602
|
+
"""Chart statistics with a shape that does not depend on the data.
|
|
603
|
+
|
|
604
|
+
:meth:`get_statistics` returns ``{'N', 'center', 'lpl', 'upl'}`` for an unstratified
|
|
605
|
+
result but ``{stratum: {...}}`` for a stratified one — so ``stats['center']`` works
|
|
606
|
+
on one dataset and raises ``KeyError`` on the next, depending on whether the chart
|
|
607
|
+
happened to stratify. This method always returns the four-key dict, and asks for a
|
|
608
|
+
stratum when the answer would otherwise be ambiguous.
|
|
609
|
+
|
|
610
|
+
Parameters
|
|
611
|
+
----------
|
|
612
|
+
chart : str
|
|
613
|
+
Chart name, e.g. ``'Xbar'``.
|
|
614
|
+
stratum : str, optional
|
|
615
|
+
Required when the result is stratified; rejected when it is not, so a call that
|
|
616
|
+
names a stratum can never silently return whole-result numbers.
|
|
617
|
+
|
|
618
|
+
Returns
|
|
619
|
+
-------
|
|
620
|
+
dict
|
|
621
|
+
Always ``{'N', 'center', 'lpl', 'upl'}`` (plus any chart-specific extras). See
|
|
622
|
+
:meth:`get_statistics` for what ``None`` values mean when limits vary.
|
|
623
|
+
|
|
624
|
+
Raises
|
|
625
|
+
------
|
|
626
|
+
ValidationError
|
|
627
|
+
If a stratum is needed and not given, given and not needed, or unknown.
|
|
628
|
+
|
|
629
|
+
Examples
|
|
630
|
+
--------
|
|
631
|
+
>>> result.statistics('Xbar')['center']
|
|
632
|
+
>>> result.statistics('X', stratum='Machine1')['upl']
|
|
633
|
+
"""
|
|
634
|
+
stats = self.get_statistics(chart)
|
|
635
|
+
|
|
636
|
+
if not self.is_stratified:
|
|
637
|
+
if stratum is not None:
|
|
638
|
+
raise ValidationError(
|
|
639
|
+
f"This result is not stratified, so stratum={stratum!r} does not apply.\n"
|
|
640
|
+
f'Use result.statistics({chart!r}) with no stratum.'
|
|
641
|
+
)
|
|
642
|
+
return stats
|
|
643
|
+
|
|
644
|
+
available = self.strata
|
|
645
|
+
if stratum is None:
|
|
646
|
+
raise ValidationError(
|
|
647
|
+
f'This result is stratified, so a stratum is required to return one set of '
|
|
648
|
+
f'statistics.\n'
|
|
649
|
+
f'Available strata: {available}\n'
|
|
650
|
+
f"Use result.statistics({chart!r}, stratum={available[0]!r}) "
|
|
651
|
+
f'— or result.get_statistics({chart!r}) for every stratum at once.'
|
|
652
|
+
)
|
|
653
|
+
if stratum not in stats:
|
|
654
|
+
raise ValidationError(
|
|
655
|
+
f"Stratum {stratum!r} not found in chart {chart!r}.\nAvailable strata: {available}"
|
|
656
|
+
)
|
|
657
|
+
return dict(stats[stratum])
|
|
658
|
+
|
|
659
|
+
def get_residual(self, residual_type: str) -> pd.Series:
|
|
660
|
+
"""
|
|
661
|
+
Get a specific residual series.
|
|
662
|
+
|
|
663
|
+
Finds **stored** residuals (R1-R5, RCR1-RCR5) on the study-level snapshot and
|
|
664
|
+
**request** residuals (R6, RCR6) on this result's dataset — see
|
|
665
|
+
:meth:`_locate_residual` for why they live in different places.
|
|
666
|
+
|
|
667
|
+
Parameters
|
|
668
|
+
----------
|
|
669
|
+
residual_type : str
|
|
670
|
+
Residual code, e.g. 'R2', 'R6', or a recentered form such as 'RCR5'.
|
|
671
|
+
|
|
672
|
+
Returns
|
|
673
|
+
-------
|
|
674
|
+
Series
|
|
675
|
+
The residual values.
|
|
676
|
+
|
|
677
|
+
Raises
|
|
678
|
+
------
|
|
679
|
+
ValidationError
|
|
680
|
+
If this result carries no residuals, or the code is not present here.
|
|
681
|
+
Previously this returned an empty Series, which let callers compute on
|
|
682
|
+
nothing; the message now says which case applies and what to do.
|
|
683
|
+
|
|
684
|
+
Examples
|
|
685
|
+
--------
|
|
686
|
+
>>> r2 = result.get_residual('R2')
|
|
687
|
+
>>> r6 = study.execute(chart='Xbar', value='R6', by=['machine']).get_residual('R6')
|
|
688
|
+
"""
|
|
689
|
+
return self._locate_residual(residual_type)
|
|
690
|
+
|
|
691
|
+
def _locate_residual(self, residual_type: str) -> pd.Series:
|
|
692
|
+
"""Return a residual series from wherever it legitimately lives, or raise.
|
|
693
|
+
|
|
694
|
+
The VAS residuals come in two kinds, and they are not stored in the same place:
|
|
695
|
+
|
|
696
|
+
- **stored** (R1-R5, RCR1-RCR5) — computed during ``formulate()`` and held on the
|
|
697
|
+
study-level snapshot. Independent of the request: R5 is the same series whatever
|
|
698
|
+
``by=`` was passed.
|
|
699
|
+
- **request** (R6, RCR6) — computed during ``execute()`` onto that request's own
|
|
700
|
+
frame, from that request's ``by=``. There is no canonical study-level R6, which
|
|
701
|
+
is why ``result.residuals`` deliberately excludes it.
|
|
702
|
+
|
|
703
|
+
Looking in only one of those two places is what made ``get_residual('R6')`` report
|
|
704
|
+
"not found" about 4,000 values the same object was holding. This is the single
|
|
705
|
+
lookup; ``get_residual`` and ``plot_residuals`` both go through it so they cannot
|
|
706
|
+
diverge again.
|
|
707
|
+
"""
|
|
708
|
+
if not self.has_residuals:
|
|
709
|
+
raise ValidationError(
|
|
710
|
+
f"No residuals available for this result, so '{residual_type}' cannot be "
|
|
711
|
+
f'returned.\nResiduals require a factorial design (ADS 1-3) with factors '
|
|
712
|
+
f'and time in the formulation.'
|
|
713
|
+
)
|
|
714
|
+
|
|
715
|
+
base = residual_type.upper()
|
|
716
|
+
base = base[2:] if base.startswith('RCR') else base
|
|
717
|
+
|
|
718
|
+
# Stored residuals: the study-level snapshot (R1-R5).
|
|
719
|
+
if self._residuals is not None and residual_type in self._residuals.columns:
|
|
720
|
+
return self._residuals[residual_type].copy()
|
|
721
|
+
|
|
722
|
+
# Request residuals: only from a result that actually asked for them. The shared
|
|
723
|
+
# study-level frame never carries R6/RCR6 — a result's own frame carries them iff
|
|
724
|
+
# that request computed them — so column presence here is the correct test.
|
|
725
|
+
if base in REQUEST_RESIDUALS:
|
|
726
|
+
if residual_type in self._dataset.columns:
|
|
727
|
+
return self._dataset[residual_type].copy()
|
|
728
|
+
raise ValidationError(
|
|
729
|
+
f"'{residual_type}' is computed per request and depends on by=, so it "
|
|
730
|
+
f'exists only on a result that asked for it.\n'
|
|
731
|
+
f'This result was not executed with it.\n'
|
|
732
|
+
f"Use study.execute(chart=..., value='{base}', by=[factor])."
|
|
733
|
+
)
|
|
734
|
+
|
|
735
|
+
# Stored recentered forms (RCR1-RCR5) live on the dataset, not the snapshot.
|
|
736
|
+
if residual_type in self._dataset.columns:
|
|
737
|
+
return self._dataset[residual_type].copy()
|
|
738
|
+
|
|
739
|
+
if base in ALL_RESIDUALS:
|
|
740
|
+
available = list(self._residuals.columns) if self._residuals is not None else []
|
|
741
|
+
raise ValidationError(
|
|
742
|
+
f"Residual '{residual_type}' is not present in this result.\n"
|
|
743
|
+
f'Available here: {available or "none"}'
|
|
744
|
+
)
|
|
745
|
+
|
|
746
|
+
raise ValidationError(
|
|
747
|
+
f"'{residual_type}' is not a recognized residual.\n"
|
|
748
|
+
f'Valid codes: {", ".join(ALL_RESIDUALS)} '
|
|
749
|
+
f"(prefix with 'RC' for the recentered form, e.g. 'RCR5')."
|
|
750
|
+
)
|
|
751
|
+
|
|
752
|
+
def list_strata(self) -> list[str]:
|
|
753
|
+
"""
|
|
754
|
+
List all strata in stratified analysis.
|
|
755
|
+
|
|
756
|
+
Equivalent to accessing the ``strata`` property. Values returned
|
|
757
|
+
here are valid inputs to ``focus()``.
|
|
758
|
+
|
|
759
|
+
Returns
|
|
760
|
+
-------
|
|
761
|
+
list
|
|
762
|
+
List of stratum identifiers
|
|
763
|
+
|
|
764
|
+
Examples
|
|
765
|
+
--------
|
|
766
|
+
>>> strata = result.list_strata()
|
|
767
|
+
>>> for s in strata:
|
|
768
|
+
... focused = result.focus(s)
|
|
769
|
+
"""
|
|
770
|
+
return self.strata
|
|
771
|
+
|
|
772
|
+
def iter_charts(self):
|
|
773
|
+
"""
|
|
774
|
+
Iterate over all charts.
|
|
775
|
+
|
|
776
|
+
Yields
|
|
777
|
+
------
|
|
778
|
+
tuple of (name, data, statistics)
|
|
779
|
+
|
|
780
|
+
Examples
|
|
781
|
+
--------
|
|
782
|
+
>>> for name, data, stats in result.iter_charts():
|
|
783
|
+
... print(f"{name}: {len(data)} points, mean={stats.get('mean')}")
|
|
784
|
+
"""
|
|
785
|
+
for name, chart_info in self.charts.items():
|
|
786
|
+
yield name, chart_info['data'], chart_info['statistics']
|
|
787
|
+
|
|
788
|
+
def chart_table(
|
|
789
|
+
self, chart: str | None = None, include_signal_col: bool = True, signal_symbols: bool = True
|
|
790
|
+
) -> pd.DataFrame:
|
|
791
|
+
"""
|
|
792
|
+
Get a summary table of chart data with subgroup sizes.
|
|
793
|
+
|
|
794
|
+
Returns a clean DataFrame suitable for display in notebooks,
|
|
795
|
+
including the subgroup size (n) which affects control limit
|
|
796
|
+
calculations when n varies across subgroups.
|
|
797
|
+
|
|
798
|
+
Parameters
|
|
799
|
+
----------
|
|
800
|
+
chart : str, optional
|
|
801
|
+
Chart name (e.g., 'Xbar', 'S'). If None, uses first chart.
|
|
802
|
+
include_signal_col : bool, default True
|
|
803
|
+
Whether to include a signal indicator column
|
|
804
|
+
signal_symbols : bool, default True
|
|
805
|
+
If True, use symbols (↑/↓) for signals; if False, use -1/0/1
|
|
806
|
+
|
|
807
|
+
Returns
|
|
808
|
+
-------
|
|
809
|
+
pd.DataFrame
|
|
810
|
+
Summary table with columns:
|
|
811
|
+
- subgroup: Subgroup identifier (RSG) — when chart_data has rsg
|
|
812
|
+
- n: Number of observations in subgroup
|
|
813
|
+
- value: The statistic value (xbar, s, etc.)
|
|
814
|
+
- center: Center line value
|
|
815
|
+
- lpl: Lower process limit
|
|
816
|
+
- upl: Upper process limit
|
|
817
|
+
- signal: Signal indicator (if include_signal_col=True)
|
|
818
|
+
|
|
819
|
+
Examples
|
|
820
|
+
--------
|
|
821
|
+
Display subgroup summary in notebook:
|
|
822
|
+
|
|
823
|
+
>>> result = study.execute()
|
|
824
|
+
>>> result.chart_table('S')
|
|
825
|
+
subgroup n value center lpl upl signal
|
|
826
|
+
0 1_1 99 1.241 1.289 1.012 1.565
|
|
827
|
+
1 1_2 98 0.977 1.289 1.011 1.567 ↓
|
|
828
|
+
2 2_1 100 0.902 1.289 1.014 1.564 ↓
|
|
829
|
+
...
|
|
830
|
+
|
|
831
|
+
Get table for Xbar chart:
|
|
832
|
+
|
|
833
|
+
>>> result.chart_table('Xbar')
|
|
834
|
+
|
|
835
|
+
Use numeric signal values:
|
|
836
|
+
|
|
837
|
+
>>> result.chart_table('S', signal_symbols=False)
|
|
838
|
+
"""
|
|
839
|
+
from .result_tabular import build_chart_table
|
|
840
|
+
|
|
841
|
+
return build_chart_table(
|
|
842
|
+
self,
|
|
843
|
+
chart=chart,
|
|
844
|
+
include_signal_col=include_signal_col,
|
|
845
|
+
signal_symbols=signal_symbols,
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
def get_signals(self, chart_name: str | None = None) -> pd.DataFrame:
|
|
849
|
+
"""
|
|
850
|
+
Get points beyond control limits.
|
|
851
|
+
|
|
852
|
+
Parameters
|
|
853
|
+
----------
|
|
854
|
+
chart_name : str, optional
|
|
855
|
+
Chart name to check. If None, checks all charts.
|
|
856
|
+
|
|
857
|
+
Returns
|
|
858
|
+
-------
|
|
859
|
+
DataFrame
|
|
860
|
+
Rows where beyond_limits != 0
|
|
861
|
+
|
|
862
|
+
Examples
|
|
863
|
+
--------
|
|
864
|
+
>>> signals = result.get_signals('Xbar')
|
|
865
|
+
>>> all_signals = result.get_signals() # All charts
|
|
866
|
+
"""
|
|
867
|
+
from .result_tabular import build_signals_table
|
|
868
|
+
|
|
869
|
+
return build_signals_table(self, chart=chart_name)
|
|
870
|
+
|
|
871
|
+
# =========================================================================
|
|
872
|
+
# String representation
|
|
873
|
+
# =========================================================================
|
|
874
|
+
|
|
875
|
+
def __repr__(self) -> str:
|
|
876
|
+
"""String representation."""
|
|
877
|
+
charts_str = ', '.join(self.all_charts)
|
|
878
|
+
return (
|
|
879
|
+
f'AnalysisResult(\n'
|
|
880
|
+
f' analytical_sds={self.analytical_sds} ({self.analytical_sds_info["description"]}),\n'
|
|
881
|
+
f' charts=[{charts_str}],\n'
|
|
882
|
+
f' n_obs={len(self._dataset)},\n'
|
|
883
|
+
f' has_residuals={self.has_residuals},\n'
|
|
884
|
+
f' has_effects={self.has_effects},\n'
|
|
885
|
+
f' n_signals={self.summary["n_signals_total"]}\n'
|
|
886
|
+
f')'
|
|
887
|
+
)
|
|
888
|
+
|
|
889
|
+
def __str__(self) -> str:
|
|
890
|
+
"""User-friendly string representation."""
|
|
891
|
+
lines = [
|
|
892
|
+
'=' * 70,
|
|
893
|
+
'ANALYSIS RESULT SUMMARY',
|
|
894
|
+
'=' * 70,
|
|
895
|
+
f'\nAnalytical Design State (ADS): {self.analytical_sds}',
|
|
896
|
+
f'Description: {self.analytical_sds_info["description"]}',
|
|
897
|
+
f'\nAnalysis Type: {self.summary["analysis_type"]}',
|
|
898
|
+
f'Response Variable: {self.summary["response_var"]}',
|
|
899
|
+
]
|
|
900
|
+
|
|
901
|
+
if self.summary['grouping_vars']:
|
|
902
|
+
lines.append(f'Grouping: {", ".join(self.summary["grouping_vars"])}')
|
|
903
|
+
|
|
904
|
+
if self.summary['time_var']:
|
|
905
|
+
lines.append(f'Time Variable: {self.summary["time_var"]}')
|
|
906
|
+
|
|
907
|
+
lines.extend(
|
|
908
|
+
[
|
|
909
|
+
f'\nObservations: {self.summary["n_observations"]}',
|
|
910
|
+
f'Charts: {", ".join(self.all_charts)}',
|
|
911
|
+
]
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
if self.summary['is_stratified']:
|
|
915
|
+
lines.append(f'Stratified: Yes ({len(self.charts)} groups)')
|
|
916
|
+
|
|
917
|
+
lines.append('\nCapabilities:')
|
|
918
|
+
lines.append(f' Residuals: {"✓" if self.has_residuals else "✗"}')
|
|
919
|
+
lines.append(f' Effects: {"✓" if self.has_effects else "✗"}')
|
|
920
|
+
lines.append(f' Interactions: {"✓" if self.has_interactions else "✗"}')
|
|
921
|
+
|
|
922
|
+
if self.summary['n_signals_total'] > 0:
|
|
923
|
+
lines.append(f'\n⚠️ Signals: {self.summary["n_signals_total"]} points beyond limits')
|
|
924
|
+
|
|
925
|
+
lines.append('=' * 70)
|
|
926
|
+
|
|
927
|
+
return '\n'.join(lines)
|
|
928
|
+
|
|
929
|
+
# =========================================================================
|
|
930
|
+
# Excel Export
|
|
931
|
+
# =========================================================================
|
|
932
|
+
|
|
933
|
+
def to_excel(self, filepath: str, **kwargs) -> list[Path]:
|
|
934
|
+
"""
|
|
935
|
+
Export analysis results to Excel with each component on a separate tab.
|
|
936
|
+
|
|
937
|
+
Creates a multi-sheet Excel workbook with organized analysis results.
|
|
938
|
+
See ExcelExporter for full documentation and options.
|
|
939
|
+
|
|
940
|
+
Parameters
|
|
941
|
+
----------
|
|
942
|
+
filepath : str
|
|
943
|
+
Output Excel file path (e.g., 'analysis.xlsx')
|
|
944
|
+
**kwargs
|
|
945
|
+
Additional options passed to ExcelExporter.export():
|
|
946
|
+
- include_summary : bool, default=True
|
|
947
|
+
- include_charts : bool, default=True
|
|
948
|
+
- include_residuals : bool, default=True
|
|
949
|
+
- include_effects : bool, default=True
|
|
950
|
+
- include_interactions : bool, default=True
|
|
951
|
+
- include_full_dataset : bool, default=False
|
|
952
|
+
- format_cells : bool, default=True
|
|
953
|
+
- include_chart_images : bool, default=True
|
|
954
|
+
- export_html : bool, default=False
|
|
955
|
+
|
|
956
|
+
Returns
|
|
957
|
+
-------
|
|
958
|
+
list[Path]
|
|
959
|
+
Every file written. With ``export_html=True`` that includes the
|
|
960
|
+
companion HTML documents, so nothing lands on disk unannounced.
|
|
961
|
+
|
|
962
|
+
Examples
|
|
963
|
+
--------
|
|
964
|
+
>>> result.to_excel('analysis.xlsx')
|
|
965
|
+
[PosixPath('analysis.xlsx')]
|
|
966
|
+
>>> result.to_excel('full.xlsx', include_full_dataset=True) # doctest: +SKIP
|
|
967
|
+
"""
|
|
968
|
+
from .exporters import ExcelExporter
|
|
969
|
+
|
|
970
|
+
return ExcelExporter(self).export(filepath, **kwargs)
|
|
971
|
+
|
|
972
|
+
def detect_signals(
|
|
973
|
+
self, chart: str | None = None, rules: str | list[str] | None = None, config: Any | None = None, **kwargs
|
|
974
|
+
) -> SignalResult | dict[str, SignalResult]:
|
|
975
|
+
"""
|
|
976
|
+
Detect Western Electric rule violations in control charts.
|
|
977
|
+
|
|
978
|
+
This method applies configurable pattern detection rules to identify
|
|
979
|
+
non-random patterns in control chart data.
|
|
980
|
+
|
|
981
|
+
Parameters
|
|
982
|
+
----------
|
|
983
|
+
chart : str, optional
|
|
984
|
+
Specific chart to analyze. If None, analyzes all charts.
|
|
985
|
+
rules : str, list, or RuleSet, optional
|
|
986
|
+
Rules to apply:
|
|
987
|
+
- 'standard': Rules 1-4 (default)
|
|
988
|
+
- 'extended': Rules 1-8
|
|
989
|
+
- 'all': All available rules
|
|
990
|
+
- List of rule names: ['rule_1', 'rule_2', ...]
|
|
991
|
+
- RuleSet: Fluent API configuration
|
|
992
|
+
config : SignalConfig, optional
|
|
993
|
+
Advanced configuration object
|
|
994
|
+
**kwargs
|
|
995
|
+
Additional parameters passed to SignalConfig
|
|
996
|
+
|
|
997
|
+
Returns
|
|
998
|
+
-------
|
|
999
|
+
SignalResult or dict of SignalResult
|
|
1000
|
+
If chart specified: single SignalResult
|
|
1001
|
+
If no chart: dict mapping chart names to SignalResults
|
|
1002
|
+
|
|
1003
|
+
Examples
|
|
1004
|
+
--------
|
|
1005
|
+
Simple usage (standard rules):
|
|
1006
|
+
|
|
1007
|
+
>>> signals = result.detect_signals()
|
|
1008
|
+
>>> print(signals.summary)
|
|
1009
|
+
|
|
1010
|
+
Specific chart and rules:
|
|
1011
|
+
|
|
1012
|
+
>>> signals = result.detect_signals(
|
|
1013
|
+
... chart='Xbar',
|
|
1014
|
+
... rules=['rule_1', 'rule_2', 'rule_5']
|
|
1015
|
+
... )
|
|
1016
|
+
|
|
1017
|
+
Using fluent API:
|
|
1018
|
+
|
|
1019
|
+
>>> from processbehavior.signals import RuleSet
|
|
1020
|
+
>>> signals = result.detect_signals(
|
|
1021
|
+
... rules=RuleSet()
|
|
1022
|
+
... .beyond_limits()
|
|
1023
|
+
... .zone_a(consecutive=2, window=3)
|
|
1024
|
+
... .trend(length=6)
|
|
1025
|
+
... )
|
|
1026
|
+
|
|
1027
|
+
Full configuration:
|
|
1028
|
+
|
|
1029
|
+
>>> from processbehavior.signals import SignalConfig
|
|
1030
|
+
>>> config = SignalConfig(
|
|
1031
|
+
... enabled_rules=['rule_1', 'rule_2'],
|
|
1032
|
+
... min_observations=30,
|
|
1033
|
+
... ignore_first_n=5
|
|
1034
|
+
... )
|
|
1035
|
+
>>> signals = result.detect_signals(config=config)
|
|
1036
|
+
|
|
1037
|
+
Access violations:
|
|
1038
|
+
|
|
1039
|
+
>>> signals.by_rule['rule_2'] # Rule 2 violations
|
|
1040
|
+
>>> signals.flagged_observations # Set of obs_ids
|
|
1041
|
+
>>> signals.to_excel('violations.xlsx')
|
|
1042
|
+
"""
|
|
1043
|
+
from .result_signals import detect_signals_for_result
|
|
1044
|
+
|
|
1045
|
+
return detect_signals_for_result(
|
|
1046
|
+
self, chart=chart, rules=rules, config=config, **kwargs
|
|
1047
|
+
)
|
|
1048
|
+
|
|
1049
|
+
def _extract_chart_type(self, chart_name: str) -> str:
|
|
1050
|
+
"""Base chart type from a chart name. Delegate for backward compat."""
|
|
1051
|
+
from .result_signals import extract_chart_type
|
|
1052
|
+
|
|
1053
|
+
return extract_chart_type(chart_name)
|
|
1054
|
+
|
|
1055
|
+
def plot(
|
|
1056
|
+
self,
|
|
1057
|
+
chart: str | None = None,
|
|
1058
|
+
facet: bool = False,
|
|
1059
|
+
ncols: int = 2,
|
|
1060
|
+
highlight_signals: bool = True,
|
|
1061
|
+
show_limits: bool = True,
|
|
1062
|
+
show_limit_values: bool = True,
|
|
1063
|
+
show_zones: bool = False,
|
|
1064
|
+
show_rules: bool = False,
|
|
1065
|
+
show_stats: bool = False,
|
|
1066
|
+
theme: ThemeName | str = 'processbehavior',
|
|
1067
|
+
width: int = 1000,
|
|
1068
|
+
height: int | None = None,
|
|
1069
|
+
aspect_ratio: float | None = None,
|
|
1070
|
+
title: str | None = None,
|
|
1071
|
+
xaxis_title: str | None = None,
|
|
1072
|
+
yaxis_title: str | None = None,
|
|
1073
|
+
shared_yaxis: bool = True,
|
|
1074
|
+
yaxis_padding: float = 0.05,
|
|
1075
|
+
vertical_spacing: float = 0.15,
|
|
1076
|
+
) -> ControlChartFigure:
|
|
1077
|
+
"""Create interactive control chart visualisation.
|
|
1078
|
+
|
|
1079
|
+
Parameters
|
|
1080
|
+
----------
|
|
1081
|
+
chart : str, optional
|
|
1082
|
+
Specific chart to plot ('Xbar', 'S', 'X', etc.).
|
|
1083
|
+
Also accepts 'Effects', 'MainEffects', 'TimeEffects',
|
|
1084
|
+
'TimeInteraction', 'FactorInteraction'.
|
|
1085
|
+
If None, plots all available charts.
|
|
1086
|
+
facet : bool, default False
|
|
1087
|
+
Whether to create faceted plot for stratified data.
|
|
1088
|
+
ncols : int, default 2
|
|
1089
|
+
Number of columns in faceted layout.
|
|
1090
|
+
highlight_signals : bool, default True
|
|
1091
|
+
Highlight points beyond control limits.
|
|
1092
|
+
show_limits : bool, default True
|
|
1093
|
+
Show control limit lines.
|
|
1094
|
+
show_limit_values : bool, default True
|
|
1095
|
+
Show numeric values in limit labels.
|
|
1096
|
+
show_zones : bool, default False
|
|
1097
|
+
Show zone shading (A/B/C zones at 1-2-3 sigma).
|
|
1098
|
+
show_rules : bool, default False
|
|
1099
|
+
Show additional run rules (WECO Rules 2-8).
|
|
1100
|
+
show_stats : bool, default False
|
|
1101
|
+
Show statistics box with CL, UPL, LPL values.
|
|
1102
|
+
theme : str, default 'processbehavior'
|
|
1103
|
+
Visual theme ('processbehavior', 'minimal', 'dark', 'ggplot',
|
|
1104
|
+
'publication') or a ChartTheme instance.
|
|
1105
|
+
width : int, default 1000
|
|
1106
|
+
Figure width in pixels.
|
|
1107
|
+
height : int, optional
|
|
1108
|
+
Figure height in pixels (auto-calculated if None).
|
|
1109
|
+
aspect_ratio : float, optional
|
|
1110
|
+
Width-to-height ratio (overrides height if specified).
|
|
1111
|
+
title : str, optional
|
|
1112
|
+
Custom title for the figure.
|
|
1113
|
+
xaxis_title, yaxis_title : str, optional
|
|
1114
|
+
Custom axis labels.
|
|
1115
|
+
shared_yaxis : bool, default True
|
|
1116
|
+
Whether faceted charts share y-axis range.
|
|
1117
|
+
yaxis_padding : float, default 0.05
|
|
1118
|
+
Padding fraction for y-axis range.
|
|
1119
|
+
vertical_spacing : float, default 0.15
|
|
1120
|
+
Vertical spacing between rows in faceted layouts.
|
|
1121
|
+
|
|
1122
|
+
Returns
|
|
1123
|
+
-------
|
|
1124
|
+
ControlChartFigure
|
|
1125
|
+
Interactive figure object with .show(), .save_html(), .save_image()
|
|
1126
|
+
|
|
1127
|
+
Examples
|
|
1128
|
+
--------
|
|
1129
|
+
>>> fig = result.plot()
|
|
1130
|
+
>>> fig = result.plot(chart='Xbar', show_zones=True, show_stats=True)
|
|
1131
|
+
>>> fig = result.plot(facet=True, ncols=3)
|
|
1132
|
+
"""
|
|
1133
|
+
from .plotting import Plotter
|
|
1134
|
+
|
|
1135
|
+
if chart:
|
|
1136
|
+
chart = self._resolve_chart_name(chart)
|
|
1137
|
+
|
|
1138
|
+
plotter = Plotter(self)
|
|
1139
|
+
return plotter.plot(
|
|
1140
|
+
chart=chart,
|
|
1141
|
+
facet=facet,
|
|
1142
|
+
ncols=ncols,
|
|
1143
|
+
highlight_signals=highlight_signals,
|
|
1144
|
+
show_limits=show_limits,
|
|
1145
|
+
show_limit_values=show_limit_values,
|
|
1146
|
+
show_zones=show_zones,
|
|
1147
|
+
show_rules=show_rules,
|
|
1148
|
+
show_stats=show_stats,
|
|
1149
|
+
theme=theme,
|
|
1150
|
+
width=width,
|
|
1151
|
+
height=height,
|
|
1152
|
+
aspect_ratio=aspect_ratio,
|
|
1153
|
+
title=title,
|
|
1154
|
+
xaxis_title=xaxis_title,
|
|
1155
|
+
yaxis_title=yaxis_title,
|
|
1156
|
+
shared_yaxis=shared_yaxis,
|
|
1157
|
+
yaxis_padding=yaxis_padding,
|
|
1158
|
+
vertical_spacing=vertical_spacing,
|
|
1159
|
+
)
|
|
1160
|
+
|
|
1161
|
+
def plot_residuals(
|
|
1162
|
+
self,
|
|
1163
|
+
residual_type: str = 'R1',
|
|
1164
|
+
plot_type: str = 'all',
|
|
1165
|
+
theme: ThemeName | str = 'processbehavior',
|
|
1166
|
+
width: int = 1200,
|
|
1167
|
+
height: int = 400,
|
|
1168
|
+
) -> ControlChartFigure:
|
|
1169
|
+
"""Create residual diagnostic plots.
|
|
1170
|
+
|
|
1171
|
+
Parameters
|
|
1172
|
+
----------
|
|
1173
|
+
residual_type : str, default 'R1'
|
|
1174
|
+
Which residual. Stored codes ('R1'-'R5') or a request code ('R6') on a
|
|
1175
|
+
result that was executed with it.
|
|
1176
|
+
plot_type : str, default 'all'
|
|
1177
|
+
'histogram', 'qq', 'sequence', or 'all'.
|
|
1178
|
+
theme : str, default 'processbehavior'
|
|
1179
|
+
Visual theme.
|
|
1180
|
+
width, height : int
|
|
1181
|
+
Figure dimensions.
|
|
1182
|
+
|
|
1183
|
+
Returns
|
|
1184
|
+
-------
|
|
1185
|
+
ControlChartFigure
|
|
1186
|
+
"""
|
|
1187
|
+
from .plotting.residuals import plot_residuals as _plot_residuals
|
|
1188
|
+
|
|
1189
|
+
return _plot_residuals(
|
|
1190
|
+
self,
|
|
1191
|
+
residual_type=residual_type,
|
|
1192
|
+
plot_type=plot_type,
|
|
1193
|
+
theme=theme,
|
|
1194
|
+
width=width,
|
|
1195
|
+
height=height,
|
|
1196
|
+
)
|
|
1197
|
+
|
|
1198
|
+
def plot_effects(
|
|
1199
|
+
self,
|
|
1200
|
+
effect_type: str = 'factor',
|
|
1201
|
+
theme: ThemeName | str = 'processbehavior',
|
|
1202
|
+
width: int = 800,
|
|
1203
|
+
height: int = 500,
|
|
1204
|
+
) -> ControlChartFigure:
|
|
1205
|
+
"""Create bar chart of main effects.
|
|
1206
|
+
|
|
1207
|
+
Parameters
|
|
1208
|
+
----------
|
|
1209
|
+
effect_type : str, default 'factor'
|
|
1210
|
+
'factor', 'time', or 'all'.
|
|
1211
|
+
theme : str, default 'processbehavior'
|
|
1212
|
+
Visual theme.
|
|
1213
|
+
width, height : int
|
|
1214
|
+
Figure dimensions.
|
|
1215
|
+
|
|
1216
|
+
Returns
|
|
1217
|
+
-------
|
|
1218
|
+
ControlChartFigure
|
|
1219
|
+
"""
|
|
1220
|
+
from .plotting import Plotter
|
|
1221
|
+
|
|
1222
|
+
plotter = Plotter(self)
|
|
1223
|
+
return plotter.plot_effects(
|
|
1224
|
+
effect_type=effect_type,
|
|
1225
|
+
theme=theme,
|
|
1226
|
+
width=width,
|
|
1227
|
+
height=height,
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
def report(
|
|
1231
|
+
self,
|
|
1232
|
+
filepath: str,
|
|
1233
|
+
include_charts: bool = True,
|
|
1234
|
+
include_residuals: bool = True,
|
|
1235
|
+
include_effects: bool = True,
|
|
1236
|
+
include_summary: bool = True,
|
|
1237
|
+
theme: ThemeName | str = 'processbehavior',
|
|
1238
|
+
width: int = 1200,
|
|
1239
|
+
title: str | None = None,
|
|
1240
|
+
) -> None:
|
|
1241
|
+
"""Generate comprehensive HTML report.
|
|
1242
|
+
|
|
1243
|
+
Parameters
|
|
1244
|
+
----------
|
|
1245
|
+
filepath : str
|
|
1246
|
+
Output HTML file path.
|
|
1247
|
+
include_charts, include_residuals, include_effects, include_summary : bool
|
|
1248
|
+
Sections to include.
|
|
1249
|
+
theme : str, default 'processbehavior'
|
|
1250
|
+
Visual theme.
|
|
1251
|
+
width : int, default 1200
|
|
1252
|
+
Chart width in pixels.
|
|
1253
|
+
title : str, optional
|
|
1254
|
+
Report title.
|
|
1255
|
+
"""
|
|
1256
|
+
from .plotting.report import generate_report
|
|
1257
|
+
|
|
1258
|
+
generate_report(
|
|
1259
|
+
self,
|
|
1260
|
+
filepath=filepath,
|
|
1261
|
+
include_charts=include_charts,
|
|
1262
|
+
include_residuals=include_residuals,
|
|
1263
|
+
include_effects=include_effects,
|
|
1264
|
+
include_summary=include_summary,
|
|
1265
|
+
theme=theme,
|
|
1266
|
+
width=width,
|
|
1267
|
+
title=title,
|
|
1268
|
+
)
|
|
1269
|
+
|
|
1270
|
+
|
|
1271
|
+
|
|
1272
|
+
# Re-export FocusedAnalysisResult for backward compat - the class lives
|
|
1273
|
+
# in focused_result.py now, but old imports of the form
|
|
1274
|
+
# `from processbehavior.analysis_result import FocusedAnalysisResult` and
|
|
1275
|
+
# any pickled instances continue to resolve through this module path.
|
|
1276
|
+
from .focused_result import FocusedAnalysisResult # noqa: E402, F401
|