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,329 @@
1
+ """Stratified-result drill-down: `focus_on(result, stratum)` + `FocusedAnalysisResult`.
2
+
3
+ Extracted from `AnalysisResult.focus()` and the inline `FocusedAnalysisResult`
4
+ subclass so the stratification subsystem lives in one place. The public API
5
+ on `AnalysisResult.focus(stratum)` is preserved as a thin delegate to
6
+ `focus_on(...)` here.
7
+
8
+ `FocusedAnalysisResult` is re-exported from `analysis_result` for
9
+ backward compat with `from processbehavior.analysis_result import
10
+ FocusedAnalysisResult` (any pickled instances continue to resolve too).
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ from typing import Any
16
+
17
+ # Import AnalysisResult at runtime (not just TYPE_CHECKING) because we
18
+ # subclass it. analysis_result.py uses lazy imports inside methods to
19
+ # avoid the circular path.
20
+ from .analysis_result import AnalysisResult
21
+ from .data_preparation import encode_rsg
22
+ from .exceptions import ProcessBehaviorError, ValidationError
23
+
24
+ # ---------------------------------------------------------------------------
25
+ # Public: focus_on
26
+ # ---------------------------------------------------------------------------
27
+
28
+
29
+ def focus_on(result: AnalysisResult, stratum: str) -> AnalysisResult:
30
+ """Return a new `FocusedAnalysisResult` restricted to `stratum`.
31
+
32
+ Parameters mirror `AnalysisResult.focus` exactly. See that docstring
33
+ for the contract.
34
+
35
+ Raises
36
+ ------
37
+ ValidationError
38
+ If `result` is not stratified, the stratum is missing, or the
39
+ focused data ends up empty (encoding mismatch).
40
+ ProcessBehaviorError
41
+ If the chart lacks both stratify_col metadata and an rsg column.
42
+ """
43
+ if not result.strata:
44
+ raise ValidationError(
45
+ "Cannot focus: this result is not stratified. "
46
+ "Use result.strata to check available subgroups."
47
+ )
48
+ if stratum not in result.strata:
49
+ raise ValidationError(
50
+ f"Stratum '{stratum}' not found. Available strata: {result.strata}"
51
+ )
52
+
53
+ focused_charts: dict[str, dict[str, Any]] = {}
54
+ for chart_name, chart_info in result.charts.items():
55
+ if not chart_info.get("strata"):
56
+ # Non-stratified chart in a stratified result — keep verbatim.
57
+ focused_charts[chart_name] = chart_info.copy()
58
+ continue
59
+
60
+ focused_charts[chart_name] = _build_focused_chart_info(
61
+ chart_name, chart_info, stratum
62
+ )
63
+
64
+ return FocusedAnalysisResult(
65
+ charts=focused_charts, original_result=result, focused_stratum=stratum
66
+ )
67
+
68
+
69
+ # ---------------------------------------------------------------------------
70
+ # Internal helpers (per-chart focusing)
71
+ # ---------------------------------------------------------------------------
72
+
73
+
74
+ def _build_focused_chart_info(
75
+ chart_name: str, chart_info: dict[str, Any], stratum: str
76
+ ) -> dict[str, Any]:
77
+ """Filter one chart's data + statistics + metadata to a single stratum."""
78
+ data = chart_info["data"]
79
+ parent_metadata = chart_info.get("metadata", {})
80
+
81
+ mask = _stratum_mask(chart_name, data, parent_metadata, stratum)
82
+
83
+ # Reset_index so focused row positions are 0..n-1. The renderer falls
84
+ # back to `data.index` for trace x when there's no explicit x_col
85
+ # (integer-position axis); without the reset, that index inherits
86
+ # the unfiltered data's range and the tick positions (which use iloc
87
+ # 0..n-1) land left of the rendered data. Same hazard
88
+ # _get_stratified_charts already handles.
89
+ focused_data = data[mask].copy().reset_index(drop=True)
90
+
91
+ if focused_data.empty:
92
+ raise ValidationError(
93
+ f"No data found for stratum '{stratum}' in chart '{chart_name}'. "
94
+ "This may indicate an encoding mismatch between strata keys and rsg values."
95
+ )
96
+
97
+ focused_stats = _extract_stratum_statistics(chart_name, chart_info, stratum)
98
+ focused_lb = _extract_stratum_lane_boundaries(parent_metadata, stratum)
99
+
100
+ return {
101
+ "data": focused_data,
102
+ "statistics": focused_stats,
103
+ "metadata": {
104
+ **parent_metadata,
105
+ "stratified": False,
106
+ "focused_stratum": stratum,
107
+ "lane_boundaries": focused_lb,
108
+ },
109
+ }
110
+
111
+
112
+ def _stratum_mask(chart_name: str, data, parent_metadata: dict[str, Any], stratum: str):
113
+ """Boolean mask selecting rows of `data` that belong to `stratum`."""
114
+ stratify_col = parent_metadata.get("stratify_col")
115
+ if stratify_col and stratify_col in data.columns:
116
+ return data[stratify_col] == stratum
117
+
118
+ # Legacy fallback: locate an rsg-like column.
119
+ rsg_col = next(
120
+ (
121
+ col for col in data.columns
122
+ if col in ("rsg", "RSG") or "rsg" in col.lower()
123
+ ),
124
+ None,
125
+ )
126
+ if rsg_col is None:
127
+ raise ProcessBehaviorError(
128
+ f"Cannot focus stratified chart '{chart_name}': "
129
+ "missing stratification column metadata and no rsg column found. "
130
+ "This indicates a bug in chart construction."
131
+ )
132
+ return data[rsg_col].astype(str) == encode_rsg(stratum)
133
+
134
+
135
+ def _extract_stratum_statistics(
136
+ chart_name: str, chart_info: dict[str, Any], stratum: str
137
+ ) -> Any:
138
+ """Pull this stratum's stats dict from the chart's nested statistics.
139
+
140
+ The dict can be keyed by either the raw stratum label or its rsg-encoded
141
+ form. Raises if the chart is stratified but neither key matches.
142
+ """
143
+ nested = chart_info.get("statistics", {})
144
+ if isinstance(nested, dict):
145
+ if stratum in nested:
146
+ return nested[stratum]
147
+ encoded = encode_rsg(stratum)
148
+ if encoded in nested:
149
+ return nested[encoded]
150
+ if chart_info.get("strata"):
151
+ raise ProcessBehaviorError(
152
+ f"Statistics key mismatch for stratum '{stratum}' in chart "
153
+ f"'{chart_name}'. Available keys: {list(nested.keys())}"
154
+ )
155
+ return nested # flat stats dict — OK
156
+
157
+
158
+ def _extract_stratum_lane_boundaries(
159
+ parent_metadata: dict[str, Any], stratum: str
160
+ ) -> Any:
161
+ """Unpack the parent's per-stratum `lane_boundaries` to this stratum's list.
162
+
163
+ Parent shape can be:
164
+ - dict {stratum: [boundary_dicts]} — extract this stratum's list
165
+ - list — already flat, pass through
166
+ - None / anything else — return None
167
+
168
+ Without this unpack, the plotter silently consumes the dict via
169
+ `next(iter(...))` and uses the first stratum's positions on every
170
+ focused chart. See processbehavior/plotting/x_axis_layout.py.
171
+ """
172
+ raw_lb = parent_metadata.get("lane_boundaries")
173
+ if isinstance(raw_lb, dict):
174
+ focused = raw_lb.get(stratum) or raw_lb.get(encode_rsg(stratum))
175
+ if focused is None and stratum in raw_lb:
176
+ focused = raw_lb[stratum]
177
+ return focused
178
+ if isinstance(raw_lb, list):
179
+ return raw_lb
180
+ return None
181
+
182
+
183
+ # ---------------------------------------------------------------------------
184
+ # FocusedAnalysisResult class
185
+ # ---------------------------------------------------------------------------
186
+
187
+
188
+ class FocusedAnalysisResult(AnalysisResult):
189
+ """
190
+ Lightweight AnalysisResult for focused (single-stratum) analysis.
191
+
192
+ Returned by `AnalysisResult.focus()`. Same public interface as
193
+ `AnalysisResult` but without requiring its own AnalysisDataSet.
194
+
195
+ Parameters
196
+ ----------
197
+ charts : dict
198
+ Chart data in standard format
199
+ original_result : AnalysisResult
200
+ The parent result this was focused from
201
+ focused_stratum : str
202
+ The stratum this result is focused on
203
+ """
204
+
205
+ def __init__(
206
+ self,
207
+ charts: dict[str, dict[str, Any]],
208
+ original_result: AnalysisResult,
209
+ focused_stratum: str,
210
+ ):
211
+ self.charts = charts
212
+ self._original = original_result
213
+ self._focused_stratum = focused_stratum
214
+ self._ads = original_result._ads
215
+ self._analysis_type = original_result._analysis_type
216
+
217
+ parent_dataset = original_result.dataset
218
+ stratum_id = encode_rsg(focused_stratum)
219
+
220
+ # Filter by the column the charts were actually stratified on. The
221
+ # rsg column is a different vocabulary whenever `by=` is a subset of
222
+ # the factors — with factors=['machine','op'] and by=['machine'], rsg
223
+ # holds 'M1_A' while the stratum is 'M1', so an rsg comparison matched
224
+ # nothing and the focused result reported zero observations.
225
+ stratify_col = self._parent_stratify_col(original_result)
226
+ if stratify_col and stratify_col in parent_dataset.columns:
227
+ mask = parent_dataset[stratify_col].astype(str) == str(focused_stratum)
228
+ self._dataset = parent_dataset[mask].copy()
229
+ rsg_col = stratify_col
230
+ else:
231
+ rsg_col = next(
232
+ (
233
+ col for col in parent_dataset.columns
234
+ if col in ("rsg", "RSG") or "rsg" in col.lower()
235
+ ),
236
+ None,
237
+ )
238
+ if rsg_col:
239
+ mask = parent_dataset[rsg_col].astype(str) == stratum_id
240
+ self._dataset = parent_dataset[mask].copy()
241
+ else:
242
+ self._dataset = parent_dataset.copy()
243
+
244
+ self.observed_sds = original_result.observed_sds
245
+ self.analytical_sds = original_result.analytical_sds
246
+ self.observed_sds_info = original_result.observed_sds_info.copy()
247
+ self.analytical_sds_info = original_result.analytical_sds_info.copy()
248
+
249
+ # Residuals — filter by rsg if possible, else align by index.
250
+ self._residuals = None
251
+ if original_result._residuals is not None:
252
+ if rsg_col and rsg_col in original_result._residuals.columns:
253
+ mask = original_result._residuals[rsg_col].astype(str) == stratum_id
254
+ self._residuals = original_result._residuals[mask].copy()
255
+ else:
256
+ self._residuals = original_result._residuals.loc[self._dataset.index].copy()
257
+
258
+ self._effects = original_result._effects
259
+ self._interactions = original_result._interactions
260
+ self._original_summary = original_result._summary.copy()
261
+ self._summary = self._build_focused_summary()
262
+
263
+ @staticmethod
264
+ def _parent_stratify_col(original_result: AnalysisResult) -> str | None:
265
+ """The column the parent's charts were stratified on, if any.
266
+
267
+ Same source `focus_on` uses to slice chart rows, so the dataset and the
268
+ charts always agree about what a stratum is.
269
+ """
270
+ for chart_info in original_result.charts.values():
271
+ stratify_col = (chart_info.get("metadata") or {}).get("stratify_col")
272
+ if stratify_col:
273
+ return str(stratify_col)
274
+ return None
275
+
276
+ def _build_focused_summary(self) -> dict:
277
+ """Summary for the focused result — n_signals re-counted, strata flag off."""
278
+ n_signals = 0
279
+ for chart_info in self.charts.values():
280
+ data = chart_info.get("data")
281
+ if data is not None and "beyond_limits" in data.columns:
282
+ n_signals += int((data["beyond_limits"] != 0).sum())
283
+
284
+ summary = self._original_summary.copy()
285
+ summary.update(
286
+ {
287
+ "n_observations": len(self._dataset),
288
+ "n_charts": len(self.charts),
289
+ "chart_types": list(self.charts.keys()),
290
+ "is_stratified": False,
291
+ "focused_stratum": self._focused_stratum,
292
+ "n_signals_total": n_signals,
293
+ }
294
+ )
295
+ return summary
296
+
297
+ @property
298
+ def strata(self) -> list[str]:
299
+ """Focused result has no strata (already single-stratum)."""
300
+ return []
301
+
302
+ @property
303
+ def is_stratified(self) -> bool:
304
+ return False
305
+
306
+ @property
307
+ def focused_stratum(self) -> str:
308
+ return self._focused_stratum
309
+
310
+ def focus(self, stratum: str) -> AnalysisResult:
311
+ """Cannot re-focus a focused result."""
312
+ raise ValidationError(
313
+ f"Cannot focus: this result is already focused on '{self._focused_stratum}'. "
314
+ "Use the original result to focus on a different stratum."
315
+ )
316
+
317
+ def __repr__(self) -> str:
318
+ charts_str = ", ".join(self.all_charts)
319
+ return (
320
+ f"FocusedAnalysisResult(\n"
321
+ f" stratum='{self._focused_stratum}',\n"
322
+ f" analytical_sds={self.analytical_sds} "
323
+ f"({self.analytical_sds_info['description']}),\n"
324
+ f" charts=[{charts_str}],\n"
325
+ f" n_obs={len(self._dataset)},\n"
326
+ f" has_residuals={self.has_residuals},\n"
327
+ f" has_effects={self.has_effects}\n"
328
+ f")"
329
+ )
@@ -0,0 +1,144 @@
1
+ """
2
+ FormulationSpec and ChartRequest - Frozen configuration for process behavior analysis.
3
+
4
+ FormulationSpec replaces DataPrepConfig + the structural half of AnalysisSpecification.
5
+ It is the single config object that flows through the system:
6
+ formulate() -> AnalysisDataSet -> DataPreparation/ResidualCalculator/EffectsCalculator
7
+
8
+ ChartRequest replaces the behavioral half of AnalysisSpecification.
9
+ It is ephemeral, created in Study.execute() and consumed by Analysis.
10
+
11
+ Together they eliminate dict intermediaries and the DataPrepConfig -> AnalysisSpecification
12
+ inheritance chain.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ from dataclasses import dataclass
18
+ from typing import TYPE_CHECKING
19
+
20
+ from .exceptions import ValidationError
21
+
22
+ if TYPE_CHECKING:
23
+ from .calibration import Calibration
24
+
25
+
26
+ @dataclass(frozen=True)
27
+ class FormulationSpec:
28
+ """Structural knowledge about the data. Created once in formulate(), immutable.
29
+
30
+ This is the single config object that flows through the system:
31
+ formulate() -> AnalysisDataSet -> DataPreparation/ResidualCalculator/EffectsCalculator
32
+
33
+ Parameters
34
+ ----------
35
+ response_var : str
36
+ Response variable (measurement) being analyzed.
37
+ rsg_vars : tuple[str, ...] or None
38
+ Rational subgrouping variables. Tuple for immutability.
39
+ time_var : str or None
40
+ Time/sequence variable for ordering observations.
41
+ round_to : int
42
+ Decimal places for rounding. Default 3.
43
+ rsg_var_name : str
44
+ Label for the composite rational subgroup column. Default 'rsg'.
45
+ rsg_var_delim : str
46
+ Delimiter for multi-variable grouping. Default '_'.
47
+ unit_of_analysis : str or None
48
+ The fundamental entity being measured (informational only).
49
+
50
+ Raises
51
+ ------
52
+ ValueError
53
+ If response_var is not provided.
54
+
55
+ Examples
56
+ --------
57
+ >>> spec = FormulationSpec(
58
+ ... response_var='Height',
59
+ ... rsg_vars=('Operator', 'Machine'),
60
+ ... time_var='Time'
61
+ ... )
62
+ >>> spec.has_grouping
63
+ True
64
+ >>> spec.response_var
65
+ 'Height'
66
+ """
67
+
68
+ response_var: str
69
+ rsg_vars: tuple[str, ...] | None = None
70
+ time_var: str | None = None
71
+ round_to: int = 3
72
+ rsg_var_name: str = 'rsg'
73
+ rsg_var_delim: str = '_'
74
+ unit_of_analysis: str | None = None
75
+
76
+ def __post_init__(self):
77
+ if self.response_var is None:
78
+ raise ValidationError('A response variable is required!')
79
+
80
+ @property
81
+ def has_grouping(self) -> bool:
82
+ """Return True if rational subgrouping variables are defined."""
83
+ return self.rsg_vars is not None
84
+
85
+ @property
86
+ def has_time(self) -> bool:
87
+ """Return True if time variable is defined."""
88
+ return self.time_var is not None
89
+
90
+ @property
91
+ def rsg_vars_list(self) -> list[str]:
92
+ """rsg_vars as a mutable list for pandas operations. Empty list if None."""
93
+ return list(self.rsg_vars) if self.rsg_vars else []
94
+
95
+ @property
96
+ def requires_sort(self) -> bool:
97
+ """Return True if data needs sorting (has time variable)."""
98
+ return self.has_time
99
+
100
+
101
+ @dataclass(frozen=True)
102
+ class ChartRequest:
103
+ """What execute() needs to run a specific chart. Ephemeral per execute() call.
104
+
105
+ Created in Study.execute(), consumed by Analysis. Never stored long-term.
106
+
107
+ Parameters
108
+ ----------
109
+ chart : str
110
+ Chart type to produce ('Xbar', 'S', 'X', 'mR', 'Histogram').
111
+ by : tuple[str, ...] or None
112
+ Factors to group/stratify by.
113
+ value_col : str or None
114
+ Explicit column to chart (response_var or residual column).
115
+ residual : str or None
116
+ Residual identifier (e.g., 'R2', 'R5') if charting a residual.
117
+ residual_chart_type : str or None
118
+ Base chart type when charting a residual.
119
+ recentered : bool
120
+ Whether to use re-centered residuals (RCR columns).
121
+ companion : bool
122
+ Whether to return both companion charts (Xbar+S or X+mR).
123
+ bins : int or None
124
+ Number of bins for Histogram chart. None for auto-binning.
125
+ phased : bool
126
+ Whether to compute per-phase limits for collapsed factors.
127
+ calibration : Calibration or None
128
+ When set, control limits use the calibration's frozen mean/sigma
129
+ (standards-given) instead of data-derived estimates. None (default)
130
+ leaves the data-derived limit path untouched.
131
+ """
132
+
133
+ chart: str
134
+ by: tuple[str, ...] | None = None
135
+ value_col: str | None = None
136
+ residual: str | None = None
137
+ residual_chart_type: str | None = None
138
+ recentered: bool = False
139
+ companion: bool = False
140
+ bins: int | None = 10
141
+ phased: bool = False
142
+ n_sigma: float = 3.0
143
+ n_mode: str = 'actual'
144
+ calibration: Calibration | None = None