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,149 @@
1
+ """Signal-detection orchestration for an `AnalysisResult`.
2
+
3
+ Wraps the `processbehavior.signals` machinery (`SignalDetector`,
4
+ `SignalConfig`, `RuleSet`) to produce SignalResults from an analysis
5
+ result's charts. Extracted from `AnalysisResult.detect_signals` so the
6
+ orchestration is unit-testable without instantiating the full result.
7
+
8
+ The class method on `AnalysisResult` remains as a thin delegate.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from typing import TYPE_CHECKING, Any
14
+
15
+ from .exceptions import ChartNotAvailableError, ProcessBehaviorError
16
+
17
+ if TYPE_CHECKING:
18
+ from .analysis_result import AnalysisResult
19
+ from .signals.result import SignalResult
20
+
21
+
22
+ # ---------------------------------------------------------------------------
23
+ # Public: detect_signals_for_result
24
+ # ---------------------------------------------------------------------------
25
+
26
+
27
+ def detect_signals_for_result(
28
+ result: AnalysisResult,
29
+ *,
30
+ chart: str | None = None,
31
+ rules: str | list[str] | Any | None = None,
32
+ config: Any | None = None,
33
+ **kwargs: Any,
34
+ ) -> SignalResult | dict[str, SignalResult]:
35
+ """Detect rule violations across one or all charts on `result`.
36
+
37
+ Parameters mirror `AnalysisResult.detect_signals` exactly. See that
38
+ docstring for the contract.
39
+
40
+ Returns one `SignalResult` when `chart` is specified, or a dict
41
+ keyed by chart name otherwise.
42
+ """
43
+ from .signals import SignalDetector
44
+
45
+ config = _build_config(rules, config, kwargs)
46
+ detector = SignalDetector()
47
+
48
+ if chart:
49
+ chart = result._resolve_chart_name(chart)
50
+ if chart not in result.charts:
51
+ raise ChartNotAvailableError(
52
+ f"Chart '{chart}' not found.\nAvailable: {result.all_charts}",
53
+ chart=chart,
54
+ available=result.all_charts,
55
+ )
56
+ return _detect_for_chart(detector, result, chart, config)
57
+
58
+ return {
59
+ name: _detect_for_chart(detector, result, name, config)
60
+ for name in result.charts
61
+ }
62
+
63
+
64
+ # ---------------------------------------------------------------------------
65
+ # Public: chart-name → base-type helper
66
+ # ---------------------------------------------------------------------------
67
+
68
+
69
+ # Base chart types whose name prefix maps to the same chart_type label.
70
+ _KNOWN_CHART_TYPES = ("Xbar", "S", "X", "mR")
71
+
72
+
73
+ def extract_chart_type(chart_name: str) -> str:
74
+ """Base chart type from a chart name.
75
+
76
+ Handles stratified chart names like 'X_lane_1' → 'X'. Falls back to
77
+ 'Xbar' for anything unrecognised (matches the legacy behaviour).
78
+ """
79
+ for prefix in _KNOWN_CHART_TYPES:
80
+ if chart_name.startswith(prefix):
81
+ return prefix
82
+ return "Xbar"
83
+
84
+
85
+ # ---------------------------------------------------------------------------
86
+ # Internal helpers
87
+ # ---------------------------------------------------------------------------
88
+
89
+
90
+ def _build_config(rules: Any, config: Any | None, kwargs: dict[str, Any]) -> Any:
91
+ """Compose the SignalConfig from the public-API arguments.
92
+
93
+ Mirrors the legacy `AnalysisResult.detect_signals` precedence:
94
+ - If `config` is provided, use it as-is (kwargs and rules ignored).
95
+ - Otherwise instantiate a default SignalConfig and apply `rules`
96
+ (str | list | RuleSet) then any matching attributes from kwargs.
97
+ """
98
+ from .signals import RuleSet, SignalConfig
99
+
100
+ if config is not None:
101
+ return config
102
+
103
+ # Route `rules` through the constructor: SignalConfig.__post_init__ is what
104
+ # resolves preset strings ('standard'/'extended'/'all') to rule lists.
105
+ # Assigning enabled_rules after construction bypasses it, leaving a raw
106
+ # string that get_rules_for_chart() rejects.
107
+ if rules is None:
108
+ config = SignalConfig()
109
+ elif isinstance(rules, RuleSet):
110
+ config = SignalConfig(enabled_rules=rules.get_rules())
111
+ else:
112
+ config = SignalConfig(enabled_rules=rules)
113
+
114
+ for key, value in kwargs.items():
115
+ if hasattr(config, key):
116
+ setattr(config, key, value)
117
+
118
+ return config
119
+
120
+
121
+ def _detect_for_chart(
122
+ detector: Any, result: AnalysisResult, chart_name: str, config: Any
123
+ ) -> SignalResult:
124
+ """Run signal detection on a single chart from `result`.
125
+
126
+ Reads `value_col` and `chart_type` from the chart's metadata
127
+ (required) and dispatches to the detector. Raises
128
+ `ProcessBehaviorError` if the chart is missing metadata.
129
+ """
130
+ chart_info = result.charts[chart_name]
131
+
132
+ metadata = chart_info.get("metadata")
133
+ if metadata is None:
134
+ raise ProcessBehaviorError(
135
+ f"Chart '{chart_name}' missing metadata. "
136
+ "This indicates a bug in chart calculation."
137
+ )
138
+
139
+ value_col = metadata["value_col"]
140
+ chart_type = metadata.get("chart_type") or extract_chart_type(chart_name)
141
+
142
+ return detector.detect(
143
+ data=chart_info["data"],
144
+ stats=chart_info["statistics"],
145
+ config=config,
146
+ value_col=value_col,
147
+ chart_name=chart_name,
148
+ chart_type=chart_type,
149
+ )
@@ -0,0 +1,296 @@
1
+ """Tabular views of an `AnalysisResult`.
2
+
3
+ Pure functions that produce DataFrames from a result. Extracted from
4
+ `AnalysisResult.chart_table` and `AnalysisResult.get_signals` so the
5
+ tabular-formatting logic is independent of the result class and directly
6
+ unit-testable (no need to construct a full AnalysisDataSet).
7
+
8
+ The class methods on `AnalysisResult` remain in place as thin delegates
9
+ to the functions here — the public API is unchanged.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import contextlib
15
+ from typing import TYPE_CHECKING
16
+
17
+ import pandas as pd
18
+
19
+ from .data_preparation import encode_rsg_series
20
+ from .exceptions import ChartNotAvailableError
21
+
22
+ if TYPE_CHECKING:
23
+ from .analysis_result import AnalysisResult
24
+
25
+
26
+ # Columns that are never the chart's "value" column — they're metadata,
27
+ # join keys, or limits. Used by `_resolve_value_col` when the response
28
+ # variable isn't directly identifiable in chart_data.
29
+ _NON_VALUE_COLS = frozenset(
30
+ {
31
+ "rsg",
32
+ "center",
33
+ "lpl",
34
+ "upl",
35
+ "beyond_limits",
36
+ "n",
37
+ "N",
38
+ "obs_id",
39
+ "x",
40
+ "pull",
41
+ "time",
42
+ "date",
43
+ "datetime",
44
+ "rsg_key",
45
+ "cell_key",
46
+ }
47
+ )
48
+
49
+ # When the response variable can't be located, prefer one of these
50
+ # canonical statistic columns before falling back to "any non-meta column".
51
+ _PREFERRED_STATISTIC_COLS = ("xbar", "s", "mr", "r")
52
+
53
+ # beyond_limits encoding → user-facing signal symbol.
54
+ _SIGNAL_SYMBOLS = {-1: "↓", 0: "", 1: "↑"}
55
+
56
+
57
+ # ---------------------------------------------------------------------------
58
+ # Public: chart_table
59
+ # ---------------------------------------------------------------------------
60
+
61
+
62
+ def build_chart_table(
63
+ result: AnalysisResult,
64
+ *,
65
+ chart: str | None = None,
66
+ include_signal_col: bool = True,
67
+ signal_symbols: bool = True,
68
+ ) -> pd.DataFrame:
69
+ """Build the summary DataFrame for one chart on `result`.
70
+
71
+ Identifies the value column, joins the per-cell `n` from the analysis
72
+ dataset if available, selects logical output columns, and optionally
73
+ converts `beyond_limits` into ↓/↑ symbols.
74
+
75
+ Parameters mirror `AnalysisResult.chart_table` exactly. See that
76
+ docstring for the column contract.
77
+ """
78
+ chart = _resolve_chart(result, chart)
79
+ chart_data = result.charts[chart]["data"].copy()
80
+
81
+ value_col = _resolve_value_col(result, chart_data)
82
+ chart_data = _join_n_column(result, chart_data)
83
+
84
+ output_cols, col_renames = _output_column_plan(
85
+ chart_data, value_col, include_signal_col, chart=chart
86
+ )
87
+ table = chart_data[output_cols].copy().rename(columns=col_renames)
88
+
89
+ if include_signal_col and "signal" in table.columns and signal_symbols:
90
+ table["signal"] = table["signal"].map(_SIGNAL_SYMBOLS)
91
+
92
+ # 1-based position index so the table's leading column matches the chart's plotted
93
+ # x-axis (which starts at 1) rather than a 0-based row counter.
94
+ table = table.reset_index(drop=True)
95
+ table.index = table.index + 1
96
+ return table
97
+
98
+
99
+ # ---------------------------------------------------------------------------
100
+ # Public: signals_table
101
+ # ---------------------------------------------------------------------------
102
+
103
+
104
+ def build_signals_table(
105
+ result: AnalysisResult,
106
+ *,
107
+ chart: str | None = None,
108
+ ) -> pd.DataFrame:
109
+ """Rows from `chart` (or all charts) where `beyond_limits != 0`.
110
+
111
+ When `chart` is None, the returned frame has a `chart` column
112
+ identifying which chart each signal row came from.
113
+ """
114
+ if chart:
115
+ data = result.get_chart(chart)
116
+ if "beyond_limits" in data.columns:
117
+ return data[data["beyond_limits"] != 0]
118
+ return pd.DataFrame()
119
+
120
+ rows: list[pd.DataFrame] = []
121
+ for name, data, _ in result.iter_charts():
122
+ if "beyond_limits" not in data.columns:
123
+ continue
124
+ signals = data[data["beyond_limits"] != 0].copy()
125
+ signals["chart"] = name
126
+ rows.append(signals)
127
+ return pd.concat(rows, ignore_index=True) if rows else pd.DataFrame()
128
+
129
+
130
+ # ---------------------------------------------------------------------------
131
+ # Internal helpers
132
+ # ---------------------------------------------------------------------------
133
+
134
+
135
+ def _resolve_chart(result: AnalysisResult, chart: str | None) -> str:
136
+ """Resolve `chart=None` to the first chart and validate existence."""
137
+ resolved = result.all_charts[0] if chart is None else result._resolve_chart_name(chart)
138
+ if resolved not in result.charts:
139
+ raise ChartNotAvailableError(
140
+ f"Chart '{resolved}' not found. Available charts: {result.all_charts}",
141
+ chart=resolved,
142
+ available=result.all_charts,
143
+ )
144
+ return resolved
145
+
146
+
147
+ def _resolve_value_col(result: AnalysisResult, chart_data: pd.DataFrame) -> str | None:
148
+ """Pick the chart's value column.
149
+
150
+ Preference order:
151
+ 1. The analysis spec's response variable, if present in chart_data.
152
+ 2. A known statistic column (`xbar`, `s`, `mr`, `r`).
153
+ 3. The first non-metadata column.
154
+ Returns None if nothing fits (degenerate chart).
155
+ """
156
+ if result._ads is not None:
157
+ response_var = result._ads.spec.response_var
158
+ if response_var in chart_data.columns:
159
+ return response_var
160
+
161
+ meta_cols = set(_NON_VALUE_COLS)
162
+ if result._ads is not None and result._ads.spec.time_var:
163
+ meta_cols.add(result._ads.spec.time_var)
164
+
165
+ value_cols = [c for c in chart_data.columns if c not in meta_cols]
166
+ for preferred in _PREFERRED_STATISTIC_COLS:
167
+ if preferred in value_cols:
168
+ return preferred
169
+ return value_cols[0] if value_cols else None
170
+
171
+
172
+ def _join_n_column(result: AnalysisResult, chart_data: pd.DataFrame) -> pd.DataFrame:
173
+ """Merge per-cell subgroup size `n` from the analysis dataset.
174
+
175
+ Returns `chart_data` unchanged when:
176
+ - `n` already in chart_data
177
+ - no analysis dataset available
178
+ - `n` not in analysis dataset
179
+ - no matching kt columns (factor × time grain)
180
+
181
+ The join keys are coerced to string on both sides when dtypes differ
182
+ (chart construction sometimes stringifies by-columns while the
183
+ underlying ads keeps them numeric). Merge failures are swallowed —
184
+ a missing `n` column is preferable to a broken table.
185
+ """
186
+ if "n" in chart_data.columns or result._ads is None:
187
+ return chart_data
188
+
189
+ ads = result._ads.analysis_dataset
190
+ spec = result._ads.spec
191
+ if "n" not in ads.columns:
192
+ return chart_data
193
+
194
+ kt_cols: list[str] = []
195
+ if spec.rsg_var_name and spec.rsg_var_name in ads.columns:
196
+ rsg_col = "rsg" if "rsg" in chart_data.columns else spec.rsg_var_name
197
+ if rsg_col in chart_data.columns:
198
+ kt_cols.append(spec.rsg_var_name)
199
+ if spec.has_time and spec.time_var in ads.columns and spec.time_var in chart_data.columns:
200
+ kt_cols.append(spec.time_var)
201
+ if not kt_cols:
202
+ # Xbar/S identify a subgroup with a single composite "group" column
203
+ # (rsg and time already joined, e.g. 'F1_1_F2_1_3') rather than the
204
+ # separate key columns X/mR carry. Rebuild the same composite on the
205
+ # ads side and join on that — otherwise `n` silently never lands on
206
+ # exactly the charts whose limits vary with it.
207
+ return _join_n_on_composite_group(ads, spec, chart_data)
208
+
209
+ n_per_kt = ads.groupby(kt_cols, observed=True)["n"].first().reset_index()
210
+
211
+ # Dtype-coerce join keys to string if either side disagrees.
212
+ mismatched = any(chart_data[c].dtype != n_per_kt[c].dtype for c in kt_cols)
213
+ if mismatched:
214
+ for c in kt_cols:
215
+ chart_data[c] = chart_data[c].astype(str)
216
+ n_per_kt[c] = n_per_kt[c].astype(str)
217
+
218
+ with contextlib.suppress(ValueError, TypeError):
219
+ chart_data = chart_data.merge(n_per_kt, on=kt_cols, how="left")
220
+ return chart_data
221
+
222
+
223
+ def _join_n_on_composite_group(ads: pd.DataFrame, spec, chart_data: pd.DataFrame) -> pd.DataFrame:
224
+ """Merge `n` onto a chart keyed by a single composite subgroup column.
225
+
226
+ Xbar/S label each subgroup with the encoded (rsg, time) cell — the same
227
+ encoding :func:`encode_rsg_series` produces — so the join key is rebuilt
228
+ rather than matched column-by-column. Returns `chart_data` unchanged if
229
+ the composite cannot be formed or does not match.
230
+ """
231
+ if "group" not in chart_data.columns:
232
+ return chart_data
233
+
234
+ key_cols = [c for c in (spec.rsg_var_name, spec.time_var) if c and c in ads.columns]
235
+ if len(key_cols) < 2:
236
+ return chart_data
237
+
238
+ n_per_cell = ads.groupby(key_cols, observed=True)["n"].first().reset_index()
239
+ n_per_cell["group"] = encode_rsg_series(n_per_cell, key_cols)
240
+ n_per_cell = n_per_cell[["group", "n"]]
241
+
242
+ chart_data = chart_data.copy()
243
+ chart_data["group"] = chart_data["group"].astype(str)
244
+
245
+ with contextlib.suppress(ValueError, TypeError):
246
+ merged = chart_data.merge(n_per_cell, on="group", how="left")
247
+ # Only adopt the merge if it actually matched; a delimiter collision
248
+ # would otherwise replace a missing column with a column of NaN.
249
+ if merged["n"].notna().any():
250
+ return merged
251
+ return chart_data
252
+
253
+
254
+ def _output_column_plan(
255
+ chart_data: pd.DataFrame,
256
+ value_col: str | None,
257
+ include_signal_col: bool,
258
+ chart: str | None = None,
259
+ ) -> tuple[list[str], dict[str, str]]:
260
+ """Decide which columns to keep and how to rename them for display."""
261
+ output_cols: list[str] = []
262
+ renames: dict[str, str] = {}
263
+
264
+ # Source-row id ("Obs") — Individuals charts only, where each row maps 1:1 to a
265
+ # single observation, so it traces back to the source data. Xbar/S rows are
266
+ # subgroup aggregates (identified by "subgroup"), so obs_id is not 1:1 there.
267
+ if chart in ("X", "mR") and "obs_id" in chart_data.columns:
268
+ output_cols.append("obs_id")
269
+ renames["obs_id"] = "Obs"
270
+
271
+ # The subgroup identifier is "rsg" on X/mR charts but "group" on Xbar/S.
272
+ # Only "rsg" was recognised, so chart_table('Xbar') silently dropped the
273
+ # subgroup column (and "n" with it) that its own docstring promises.
274
+ subgroup_col = next(
275
+ (col for col in ("rsg", "group") if col in chart_data.columns), None
276
+ )
277
+ if subgroup_col:
278
+ output_cols.append(subgroup_col)
279
+ renames[subgroup_col] = "subgroup"
280
+
281
+ if "n" in chart_data.columns:
282
+ output_cols.append("n")
283
+
284
+ if value_col:
285
+ output_cols.append(value_col)
286
+ renames[value_col] = "value"
287
+
288
+ for col in ("center", "lpl", "upl"):
289
+ if col in chart_data.columns:
290
+ output_cols.append(col)
291
+
292
+ if include_signal_col and "beyond_limits" in chart_data.columns:
293
+ output_cols.append("beyond_limits")
294
+ renames["beyond_limits"] = "signal"
295
+
296
+ return output_cols, renames