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,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
|