processbehavior 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. processbehavior/__init__.py +133 -0
  2. processbehavior/analysis.py +2944 -0
  3. processbehavior/analysis_dataset.py +361 -0
  4. processbehavior/analysis_result.py +1276 -0
  5. processbehavior/calibration.py +70 -0
  6. processbehavior/capability.py +720 -0
  7. processbehavior/data_preparation.py +792 -0
  8. processbehavior/datasets/__init__.py +37 -0
  9. processbehavior/datasets/data/coffee_shop_demo_long.csv +385 -0
  10. processbehavior/datasets/loaders.py +66 -0
  11. processbehavior/datasets/synthetic.py +1711 -0
  12. processbehavior/derivations.py +602 -0
  13. processbehavior/effects_calculator.py +603 -0
  14. processbehavior/exceptions.py +216 -0
  15. processbehavior/exporters/__init__.py +5 -0
  16. processbehavior/exporters/excel_exporter.py +708 -0
  17. processbehavior/focused_result.py +329 -0
  18. processbehavior/formulation_spec.py +144 -0
  19. processbehavior/loss_function.py +517 -0
  20. processbehavior/maximum_information.py +285 -0
  21. processbehavior/plotting/__init__.py +46 -0
  22. processbehavior/plotting/_math.py +121 -0
  23. processbehavior/plotting/capability_chart.py +858 -0
  24. processbehavior/plotting/contracts.py +176 -0
  25. processbehavior/plotting/control_chart.py +206 -0
  26. processbehavior/plotting/effects_charts.py +640 -0
  27. processbehavior/plotting/lane_boundaries.py +78 -0
  28. processbehavior/plotting/limits.py +157 -0
  29. processbehavior/plotting/maximum_information_chart.py +395 -0
  30. processbehavior/plotting/plotter.py +1469 -0
  31. processbehavior/plotting/renderers.py +660 -0
  32. processbehavior/plotting/report.py +211 -0
  33. processbehavior/plotting/residuals.py +282 -0
  34. processbehavior/plotting/run_rules_viz.py +150 -0
  35. processbehavior/plotting/stats_box.py +183 -0
  36. processbehavior/plotting/themes.py +544 -0
  37. processbehavior/plotting/x_axis_layout.py +474 -0
  38. processbehavior/plotting/zones.py +127 -0
  39. processbehavior/process_behavior.py +1290 -0
  40. processbehavior/py.typed +0 -0
  41. processbehavior/residual_calculator.py +572 -0
  42. processbehavior/result_signals.py +149 -0
  43. processbehavior/result_tabular.py +296 -0
  44. processbehavior/sds_detector.py +1432 -0
  45. processbehavior/signals/__init__.py +14 -0
  46. processbehavior/signals/config.py +435 -0
  47. processbehavior/signals/detector.py +328 -0
  48. processbehavior/signals/detectors.py +368 -0
  49. processbehavior/signals/result.py +232 -0
  50. processbehavior/spc_constants.py +692 -0
  51. processbehavior/study.py +2733 -0
  52. processbehavior/types.py +154 -0
  53. processbehavior-0.2.0.dist-info/METADATA +279 -0
  54. processbehavior-0.2.0.dist-info/RECORD +56 -0
  55. processbehavior-0.2.0.dist-info/WHEEL +4 -0
  56. processbehavior-0.2.0.dist-info/licenses/LICENSE +201 -0
@@ -0,0 +1,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