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,720 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Process Capability Analysis — Bishop Chapter 16.
|
|
3
|
+
|
|
4
|
+
Computes capability indices given specification limits. Core logic lives as
|
|
5
|
+
pure module-level functions (composition pattern matching ResidualCalculator /
|
|
6
|
+
EffectsCalculator). Power users can import ``assess_capability()`` directly.
|
|
7
|
+
|
|
8
|
+
Current Capability (how the process IS performing):
|
|
9
|
+
S = std(Y, ddof=1)
|
|
10
|
+
σ̂ = S / c4(N)
|
|
11
|
+
Pp = (USL − LSL) / 6σ̂
|
|
12
|
+
Ppk = min((Ȳ − LSL) / 3σ̂, (USL − Ȳ) / 3σ̂)
|
|
13
|
+
|
|
14
|
+
Potential Capability (achievable by removing assignable causes):
|
|
15
|
+
S_R2 = std(R2, ddof=1)
|
|
16
|
+
σ̂_R2 = S_R2 / c4(N_R2)
|
|
17
|
+
Cp = (USL − LSL) / 6σ̂_R2
|
|
18
|
+
Cpk = min((Ȳ − LSL) / 3σ̂_R2, (USL − Ȳ) / 3σ̂_R2)
|
|
19
|
+
|
|
20
|
+
References
|
|
21
|
+
----------
|
|
22
|
+
Bishop, T. *Variance Analysis System* — Chapter 16.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
from dataclasses import dataclass
|
|
28
|
+
from typing import TYPE_CHECKING, Literal
|
|
29
|
+
|
|
30
|
+
import numpy as np
|
|
31
|
+
import pandas as pd
|
|
32
|
+
|
|
33
|
+
from .exceptions import ValidationError
|
|
34
|
+
from .spc_constants import c4
|
|
35
|
+
|
|
36
|
+
if TYPE_CHECKING:
|
|
37
|
+
from .analysis_dataset import AnalysisDataSet
|
|
38
|
+
from .study import Study
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ============================================================================
|
|
42
|
+
# Data Classes
|
|
43
|
+
# ============================================================================
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class SpecLimits:
|
|
48
|
+
"""
|
|
49
|
+
Specification limits for capability analysis.
|
|
50
|
+
|
|
51
|
+
At least one of ``usl`` or ``lsl`` must be provided.
|
|
52
|
+
|
|
53
|
+
Parameters
|
|
54
|
+
----------
|
|
55
|
+
usl : float | None
|
|
56
|
+
Upper specification limit.
|
|
57
|
+
lsl : float | None
|
|
58
|
+
Lower specification limit.
|
|
59
|
+
target : float | None
|
|
60
|
+
Target value (optional). When both limits are present,
|
|
61
|
+
must satisfy ``lsl <= target <= usl``.
|
|
62
|
+
"""
|
|
63
|
+
|
|
64
|
+
usl: float | None = None
|
|
65
|
+
lsl: float | None = None
|
|
66
|
+
target: float | None = None
|
|
67
|
+
|
|
68
|
+
def __post_init__(self) -> None:
|
|
69
|
+
if self.usl is None and self.lsl is None:
|
|
70
|
+
raise ValidationError('At least one specification limit (usl or lsl) must be provided.')
|
|
71
|
+
|
|
72
|
+
if self.usl is not None and self.lsl is not None and self.lsl >= self.usl:
|
|
73
|
+
raise ValidationError(f'LSL ({self.lsl}) must be less than USL ({self.usl}).')
|
|
74
|
+
|
|
75
|
+
if (
|
|
76
|
+
self.target is not None
|
|
77
|
+
and self.usl is not None
|
|
78
|
+
and self.lsl is not None
|
|
79
|
+
and not (self.lsl <= self.target <= self.usl)
|
|
80
|
+
):
|
|
81
|
+
raise ValidationError(f'Target ({self.target}) must be between LSL ({self.lsl}) and USL ({self.usl}).')
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def is_two_sided(self) -> bool:
|
|
85
|
+
"""True when both USL and LSL are specified."""
|
|
86
|
+
return self.usl is not None and self.lsl is not None
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@dataclass(frozen=True)
|
|
90
|
+
class CapabilityResult:
|
|
91
|
+
"""
|
|
92
|
+
Result of a process capability assessment (Bishop Ch. 16).
|
|
93
|
+
|
|
94
|
+
Raw ``float`` values are stored unrounded. Use :meth:`as_dict` for a
|
|
95
|
+
rounded dictionary or ``repr()`` for a formatted summary.
|
|
96
|
+
|
|
97
|
+
Attributes
|
|
98
|
+
----------
|
|
99
|
+
specs : SpecLimits
|
|
100
|
+
Specification limits used for the assessment.
|
|
101
|
+
n : int
|
|
102
|
+
Total valid (non-NaN) observations.
|
|
103
|
+
y_bar : float
|
|
104
|
+
Grand mean of valid observations.
|
|
105
|
+
s : float
|
|
106
|
+
Sample standard deviation (ddof=1).
|
|
107
|
+
sigma_hat : float
|
|
108
|
+
Unbiased sigma estimate: ``s / c4(n)``.
|
|
109
|
+
pp, ppk_lower, ppk_upper, ppk : float | None
|
|
110
|
+
Current capability indices (from overall sigma).
|
|
111
|
+
sigma_hat_r2 : float | None
|
|
112
|
+
Unbiased sigma from R2 residuals.
|
|
113
|
+
cp, cpk_lower, cpk_upper, cpk : float | None
|
|
114
|
+
Potential capability indices (from R2 sigma).
|
|
115
|
+
potential_unavailable_reason : str | None
|
|
116
|
+
Why Cp/Cpk are unavailable (if applicable).
|
|
117
|
+
z_lower, z_upper : float | None
|
|
118
|
+
Distance to spec in sigma units.
|
|
119
|
+
n_below_lsl, n_above_usl, n_outside : int | None
|
|
120
|
+
Empirical counts outside specs (from raw observations).
|
|
121
|
+
pct_below_lsl, pct_above_usl, pct_outside : float | None
|
|
122
|
+
Empirical percents outside specs (from raw observations).
|
|
123
|
+
potential_n_below_lsl, potential_n_above_usl : int | None
|
|
124
|
+
Empirical counts outside specs (from recentered R2 values).
|
|
125
|
+
potential_pct_below_lsl, potential_pct_above_usl : float | None
|
|
126
|
+
Empirical percents outside specs (from recentered R2 values).
|
|
127
|
+
stability_evaluated : bool
|
|
128
|
+
Always False in v1 — stability integration deferred.
|
|
129
|
+
stability_warning : str | None
|
|
130
|
+
Always None in v1.
|
|
131
|
+
round_to : int
|
|
132
|
+
Decimal places used by ``__repr__`` and ``as_dict``.
|
|
133
|
+
"""
|
|
134
|
+
|
|
135
|
+
# Input context
|
|
136
|
+
specs: SpecLimits
|
|
137
|
+
n: int
|
|
138
|
+
y_bar: float
|
|
139
|
+
s: float
|
|
140
|
+
sigma_hat: float
|
|
141
|
+
|
|
142
|
+
# Current capability (from overall sigma)
|
|
143
|
+
pp: float | None
|
|
144
|
+
ppk_lower: float | None
|
|
145
|
+
ppk_upper: float | None
|
|
146
|
+
ppk: float | None
|
|
147
|
+
|
|
148
|
+
# Potential capability (from R2 residual sigma)
|
|
149
|
+
sigma_hat_r2: float | None
|
|
150
|
+
cp: float | None
|
|
151
|
+
cpk_lower: float | None
|
|
152
|
+
cpk_upper: float | None
|
|
153
|
+
cpk: float | None
|
|
154
|
+
potential_unavailable_reason: str | None
|
|
155
|
+
|
|
156
|
+
# Z-scores
|
|
157
|
+
z_lower: float | None
|
|
158
|
+
z_upper: float | None
|
|
159
|
+
|
|
160
|
+
# Empirical percent outside (from raw y_values)
|
|
161
|
+
n_below_lsl: int | None
|
|
162
|
+
n_above_usl: int | None
|
|
163
|
+
n_outside: int | None
|
|
164
|
+
pct_below_lsl: float | None
|
|
165
|
+
pct_above_usl: float | None
|
|
166
|
+
pct_outside: float | None
|
|
167
|
+
|
|
168
|
+
# Potential view data
|
|
169
|
+
potential_values: np.ndarray | None = None
|
|
170
|
+
|
|
171
|
+
# Response variable name (for axis labels)
|
|
172
|
+
response_var: str | None = None
|
|
173
|
+
|
|
174
|
+
# Potential empirical percent outside (from recentered R2 values)
|
|
175
|
+
potential_n_below_lsl: int | None = None
|
|
176
|
+
potential_n_above_usl: int | None = None
|
|
177
|
+
potential_pct_below_lsl: float | None = None
|
|
178
|
+
potential_pct_above_usl: float | None = None
|
|
179
|
+
|
|
180
|
+
# Stability context
|
|
181
|
+
stability_evaluated: bool = False
|
|
182
|
+
stability_warning: str | None = None
|
|
183
|
+
|
|
184
|
+
# Presentation
|
|
185
|
+
round_to: int = 3
|
|
186
|
+
|
|
187
|
+
# Time-window view (all None on the un-windowed default path, so the default
|
|
188
|
+
# result is byte-for-byte unchanged). Set only when a window= is supplied.
|
|
189
|
+
window: tuple | None = None
|
|
190
|
+
time_var: str | None = None
|
|
191
|
+
n_total: int | None = None
|
|
192
|
+
window_warning: str | None = None
|
|
193
|
+
observed_values: np.ndarray | None = None # values summarized (windowed or full)
|
|
194
|
+
|
|
195
|
+
# ------------------------------------------------------------------
|
|
196
|
+
# Visualization
|
|
197
|
+
# ------------------------------------------------------------------
|
|
198
|
+
|
|
199
|
+
def plot(
|
|
200
|
+
self,
|
|
201
|
+
values=None,
|
|
202
|
+
*,
|
|
203
|
+
theme=None,
|
|
204
|
+
show_potential=True,
|
|
205
|
+
view: Literal['current', 'potential'] = 'current',
|
|
206
|
+
paired=False,
|
|
207
|
+
x_label=None,
|
|
208
|
+
nbins=None,
|
|
209
|
+
histnorm='',
|
|
210
|
+
width=900,
|
|
211
|
+
height=500,
|
|
212
|
+
title=None,
|
|
213
|
+
):
|
|
214
|
+
"""
|
|
215
|
+
Create a capability histogram with spec lines and index annotations.
|
|
216
|
+
|
|
217
|
+
Parameters
|
|
218
|
+
----------
|
|
219
|
+
values : array-like, optional
|
|
220
|
+
Response values to histogram. Defaults to the values this result
|
|
221
|
+
summarized (the window subset when windowed, else the full data),
|
|
222
|
+
so a windowed result charts exactly its window with no caller effort.
|
|
223
|
+
theme : ChartTheme, optional
|
|
224
|
+
Visual theme (default: processbehavior theme).
|
|
225
|
+
show_potential : bool, default True
|
|
226
|
+
Show Cp/Cpk in the annotation box when available.
|
|
227
|
+
view : str, default ``'current'``
|
|
228
|
+
``'current'`` for NPLs from overall σ̂ (Pp/Ppk), or
|
|
229
|
+
``'potential'`` for NPLs from R2 σ̂_R2 (Cp/Cpk).
|
|
230
|
+
paired : bool, default False
|
|
231
|
+
Two-panel facet: Current (left) | Potential (right).
|
|
232
|
+
x_label, nbins, histnorm, width, height, title
|
|
233
|
+
Passed through to ``create_capability_chart()``.
|
|
234
|
+
|
|
235
|
+
Returns
|
|
236
|
+
-------
|
|
237
|
+
plotly.graph_objects.Figure
|
|
238
|
+
"""
|
|
239
|
+
from .plotting.capability_chart import create_capability_chart
|
|
240
|
+
|
|
241
|
+
if values is None:
|
|
242
|
+
values = self.observed_values
|
|
243
|
+
return create_capability_chart(
|
|
244
|
+
self,
|
|
245
|
+
values,
|
|
246
|
+
theme=theme,
|
|
247
|
+
show_potential=show_potential,
|
|
248
|
+
view=view,
|
|
249
|
+
paired=paired,
|
|
250
|
+
x_label=x_label,
|
|
251
|
+
nbins=nbins,
|
|
252
|
+
histnorm=histnorm,
|
|
253
|
+
width=width,
|
|
254
|
+
height=height,
|
|
255
|
+
title=title,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
# ------------------------------------------------------------------
|
|
259
|
+
# Presentation helpers
|
|
260
|
+
# ------------------------------------------------------------------
|
|
261
|
+
|
|
262
|
+
def as_dict(self, round_to: int | None = None) -> dict:
|
|
263
|
+
"""Return results as a dict with values rounded for display."""
|
|
264
|
+
r = round_to if round_to is not None else self.round_to
|
|
265
|
+
|
|
266
|
+
def _r(v: float | None) -> float | None:
|
|
267
|
+
if v is None:
|
|
268
|
+
return None
|
|
269
|
+
if not np.isfinite(v):
|
|
270
|
+
return v
|
|
271
|
+
return round(v, r)
|
|
272
|
+
|
|
273
|
+
out = {
|
|
274
|
+
'usl': self.specs.usl,
|
|
275
|
+
'lsl': self.specs.lsl,
|
|
276
|
+
'target': self.specs.target,
|
|
277
|
+
'n': self.n,
|
|
278
|
+
'y_bar': _r(self.y_bar),
|
|
279
|
+
's': _r(self.s),
|
|
280
|
+
'sigma_hat': _r(self.sigma_hat),
|
|
281
|
+
'pp': _r(self.pp),
|
|
282
|
+
'ppk_lower': _r(self.ppk_lower),
|
|
283
|
+
'ppk_upper': _r(self.ppk_upper),
|
|
284
|
+
'ppk': _r(self.ppk),
|
|
285
|
+
'sigma_hat_r2': _r(self.sigma_hat_r2),
|
|
286
|
+
'cp': _r(self.cp),
|
|
287
|
+
'cpk_lower': _r(self.cpk_lower),
|
|
288
|
+
'cpk_upper': _r(self.cpk_upper),
|
|
289
|
+
'cpk': _r(self.cpk),
|
|
290
|
+
'potential_unavailable_reason': self.potential_unavailable_reason,
|
|
291
|
+
'z_lower': _r(self.z_lower),
|
|
292
|
+
'z_upper': _r(self.z_upper),
|
|
293
|
+
'n_below_lsl': self.n_below_lsl,
|
|
294
|
+
'n_above_usl': self.n_above_usl,
|
|
295
|
+
'n_outside': self.n_outside,
|
|
296
|
+
'pct_below_lsl': _r(self.pct_below_lsl),
|
|
297
|
+
'pct_above_usl': _r(self.pct_above_usl),
|
|
298
|
+
'pct_outside': _r(self.pct_outside),
|
|
299
|
+
'potential_n_below_lsl': self.potential_n_below_lsl,
|
|
300
|
+
'potential_n_above_usl': self.potential_n_above_usl,
|
|
301
|
+
'potential_pct_below_lsl': _r(self.potential_pct_below_lsl),
|
|
302
|
+
'potential_pct_above_usl': _r(self.potential_pct_above_usl),
|
|
303
|
+
}
|
|
304
|
+
# Window metadata only when windowed → un-windowed dict is unchanged.
|
|
305
|
+
if self.window is not None:
|
|
306
|
+
out['window'] = self.window
|
|
307
|
+
out['time_var'] = self.time_var
|
|
308
|
+
out['n_total'] = self.n_total
|
|
309
|
+
out['window_warning'] = self.window_warning
|
|
310
|
+
return out
|
|
311
|
+
|
|
312
|
+
def __repr__(self) -> str:
|
|
313
|
+
"""Formatted summary using self.round_to."""
|
|
314
|
+
d = self.as_dict()
|
|
315
|
+
lines = ['CapabilityResult:']
|
|
316
|
+
lines.append(f' Specs: LSL={d["lsl"]}, USL={d["usl"]}, Target={d["target"]}')
|
|
317
|
+
lines.append(f' N={d["n"]}, Ybar={d["y_bar"]}, S={d["s"]}, sigma_hat={d["sigma_hat"]}')
|
|
318
|
+
lines.append('')
|
|
319
|
+
|
|
320
|
+
lines.append(' Current Capability (overall sigma):')
|
|
321
|
+
lines.append(f' Pp={d["pp"]} Ppk={d["ppk"]} (lower={d["ppk_lower"]}, upper={d["ppk_upper"]})')
|
|
322
|
+
|
|
323
|
+
lines.append('')
|
|
324
|
+
if self.potential_unavailable_reason:
|
|
325
|
+
lines.append(f' Potential Capability: {self.potential_unavailable_reason}')
|
|
326
|
+
else:
|
|
327
|
+
lines.append(' Potential Capability (R2 sigma):')
|
|
328
|
+
lines.append(f' Cp={d["cp"]} Cpk={d["cpk"]} (lower={d["cpk_lower"]}, upper={d["cpk_upper"]})')
|
|
329
|
+
|
|
330
|
+
lines.append('')
|
|
331
|
+
lines.append(' Z-scores:')
|
|
332
|
+
lines.append(f' Z_lower={d["z_lower"]}, Z_upper={d["z_upper"]}')
|
|
333
|
+
|
|
334
|
+
lines.append('')
|
|
335
|
+
lines.append(' Empirical (current):')
|
|
336
|
+
lines.append(
|
|
337
|
+
f' Below LSL: {d["n_below_lsl"]} ({d["pct_below_lsl"]}%)'
|
|
338
|
+
f' Above USL: {d["n_above_usl"]} ({d["pct_above_usl"]}%)'
|
|
339
|
+
f' Total outside: {d["n_outside"]} ({d["pct_outside"]}%)'
|
|
340
|
+
)
|
|
341
|
+
if self.potential_pct_below_lsl is not None or self.potential_pct_above_usl is not None:
|
|
342
|
+
lines.append(' Empirical (potential):')
|
|
343
|
+
lines.append(
|
|
344
|
+
f' Below LSL: {d["potential_n_below_lsl"]} ({d["potential_pct_below_lsl"]}%)'
|
|
345
|
+
f' Above USL: {d["potential_n_above_usl"]} ({d["potential_pct_above_usl"]}%)'
|
|
346
|
+
)
|
|
347
|
+
|
|
348
|
+
if not self.stability_evaluated:
|
|
349
|
+
lines.append('')
|
|
350
|
+
lines.append(
|
|
351
|
+
' Note: Stability not assessed; run study.execute() and review signals before interpreting indices.'
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
return '\n'.join(lines)
|
|
355
|
+
|
|
356
|
+
|
|
357
|
+
# ============================================================================
|
|
358
|
+
# Pure Functions
|
|
359
|
+
# ============================================================================
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def compute_sigma_hat(values: np.ndarray) -> tuple[float, float]:
|
|
363
|
+
"""
|
|
364
|
+
Compute sample std dev (ddof=1) and unbiased sigma estimate.
|
|
365
|
+
|
|
366
|
+
Parameters
|
|
367
|
+
----------
|
|
368
|
+
values : array-like
|
|
369
|
+
Non-NaN numeric values. Must have len >= 2.
|
|
370
|
+
|
|
371
|
+
Returns
|
|
372
|
+
-------
|
|
373
|
+
(s, sigma_hat) : tuple[float, float]
|
|
374
|
+
s = np.std(values, ddof=1)
|
|
375
|
+
sigma_hat = s / c4(len(values))
|
|
376
|
+
|
|
377
|
+
Notes
|
|
378
|
+
-----
|
|
379
|
+
Bishop Ch. 16 — ddof=1 is mandatory. The c4 correction
|
|
380
|
+
removes the small-sample bias inherent in the sample standard
|
|
381
|
+
deviation.
|
|
382
|
+
"""
|
|
383
|
+
n = len(values)
|
|
384
|
+
s = float(np.std(values, ddof=1))
|
|
385
|
+
sigma_hat = s / c4(n)
|
|
386
|
+
return s, sigma_hat
|
|
387
|
+
|
|
388
|
+
|
|
389
|
+
def compute_capability_indices(
|
|
390
|
+
y_bar: float,
|
|
391
|
+
sigma_hat: float,
|
|
392
|
+
specs: SpecLimits,
|
|
393
|
+
) -> dict:
|
|
394
|
+
"""
|
|
395
|
+
Compute Pp/Ppk (or Cp/Cpk) from a mean, sigma estimate, and spec limits.
|
|
396
|
+
|
|
397
|
+
Parameters
|
|
398
|
+
----------
|
|
399
|
+
y_bar : float
|
|
400
|
+
Process mean (grand mean of observations).
|
|
401
|
+
sigma_hat : float
|
|
402
|
+
Unbiased sigma estimate. When zero (all identical values),
|
|
403
|
+
indices are returned as ``float('inf')``.
|
|
404
|
+
specs : SpecLimits
|
|
405
|
+
Specification limits.
|
|
406
|
+
|
|
407
|
+
Returns
|
|
408
|
+
-------
|
|
409
|
+
dict
|
|
410
|
+
Keys: pp, ppk_lower, ppk_upper, ppk, z_lower, z_upper.
|
|
411
|
+
``None`` where the relevant limit is absent.
|
|
412
|
+
"""
|
|
413
|
+
|
|
414
|
+
def _safe_div(numerator: float, denominator: float) -> float:
|
|
415
|
+
"""Divide, returning inf when denominator is zero."""
|
|
416
|
+
if denominator == 0.0:
|
|
417
|
+
return float('inf')
|
|
418
|
+
return numerator / denominator
|
|
419
|
+
|
|
420
|
+
pp = None
|
|
421
|
+
ppk_lower = None
|
|
422
|
+
ppk_upper = None
|
|
423
|
+
z_lower = None
|
|
424
|
+
z_upper = None
|
|
425
|
+
|
|
426
|
+
if specs.lsl is not None:
|
|
427
|
+
ppk_lower = _safe_div(y_bar - specs.lsl, 3 * sigma_hat)
|
|
428
|
+
z_lower = _safe_div(y_bar - specs.lsl, sigma_hat)
|
|
429
|
+
|
|
430
|
+
if specs.usl is not None:
|
|
431
|
+
ppk_upper = _safe_div(specs.usl - y_bar, 3 * sigma_hat)
|
|
432
|
+
z_upper = _safe_div(specs.usl - y_bar, sigma_hat)
|
|
433
|
+
|
|
434
|
+
if specs.is_two_sided:
|
|
435
|
+
pp = _safe_div(specs.usl - specs.lsl, 6 * sigma_hat)
|
|
436
|
+
assert ppk_lower is not None and ppk_upper is not None
|
|
437
|
+
ppk = min(ppk_lower, ppk_upper)
|
|
438
|
+
elif ppk_lower is not None:
|
|
439
|
+
ppk = ppk_lower
|
|
440
|
+
else:
|
|
441
|
+
ppk = ppk_upper
|
|
442
|
+
|
|
443
|
+
return {
|
|
444
|
+
'pp': pp,
|
|
445
|
+
'ppk_lower': ppk_lower,
|
|
446
|
+
'ppk_upper': ppk_upper,
|
|
447
|
+
'ppk': ppk,
|
|
448
|
+
'z_lower': z_lower,
|
|
449
|
+
'z_upper': z_upper,
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def compute_pct_outside(values: np.ndarray, specs: SpecLimits) -> dict:
|
|
454
|
+
"""
|
|
455
|
+
Compute empirical counts and percents outside specification limits.
|
|
456
|
+
|
|
457
|
+
Parameters
|
|
458
|
+
----------
|
|
459
|
+
values : array-like
|
|
460
|
+
Non-NaN numeric values.
|
|
461
|
+
specs : SpecLimits
|
|
462
|
+
Specification limits.
|
|
463
|
+
|
|
464
|
+
Returns
|
|
465
|
+
-------
|
|
466
|
+
dict
|
|
467
|
+
Keys: n_below_lsl, n_above_usl, n_outside,
|
|
468
|
+
pct_below_lsl, pct_above_usl, pct_outside.
|
|
469
|
+
``None`` where the relevant limit is absent.
|
|
470
|
+
"""
|
|
471
|
+
n = len(values)
|
|
472
|
+
|
|
473
|
+
n_below = None
|
|
474
|
+
n_above = None
|
|
475
|
+
pct_below = None
|
|
476
|
+
pct_above = None
|
|
477
|
+
|
|
478
|
+
if specs.lsl is not None:
|
|
479
|
+
n_below = int(np.sum(values < specs.lsl))
|
|
480
|
+
pct_below = n_below / n * 100
|
|
481
|
+
|
|
482
|
+
if specs.usl is not None:
|
|
483
|
+
n_above = int(np.sum(values > specs.usl))
|
|
484
|
+
pct_above = n_above / n * 100
|
|
485
|
+
|
|
486
|
+
# Total outside
|
|
487
|
+
if n_below is not None and n_above is not None:
|
|
488
|
+
n_outside = n_below + n_above
|
|
489
|
+
pct_outside = pct_below + pct_above
|
|
490
|
+
elif n_below is not None:
|
|
491
|
+
n_outside = n_below
|
|
492
|
+
pct_outside = pct_below
|
|
493
|
+
else:
|
|
494
|
+
n_outside = n_above
|
|
495
|
+
pct_outside = pct_above
|
|
496
|
+
|
|
497
|
+
return {
|
|
498
|
+
'n_below_lsl': n_below,
|
|
499
|
+
'n_above_usl': n_above,
|
|
500
|
+
'n_outside': n_outside,
|
|
501
|
+
'pct_below_lsl': pct_below,
|
|
502
|
+
'pct_above_usl': pct_above,
|
|
503
|
+
'pct_outside': pct_outside,
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
# ============================================================================
|
|
508
|
+
# Coercion
|
|
509
|
+
# ============================================================================
|
|
510
|
+
|
|
511
|
+
|
|
512
|
+
def _coerce_ads(source: Study | AnalysisDataSet) -> AnalysisDataSet:
|
|
513
|
+
"""
|
|
514
|
+
Accept Study or AnalysisDataSet and return ADS.
|
|
515
|
+
|
|
516
|
+
Keeps the public ``assess_capability`` function stable if Study
|
|
517
|
+
internals shift.
|
|
518
|
+
"""
|
|
519
|
+
# Avoid circular import by checking attribute
|
|
520
|
+
if hasattr(source, '_ads'):
|
|
521
|
+
return source._ads
|
|
522
|
+
return source
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
# ============================================================================
|
|
526
|
+
# Orchestrator
|
|
527
|
+
# ============================================================================
|
|
528
|
+
|
|
529
|
+
|
|
530
|
+
def _time_window_mask(time_col: pd.Series, window: tuple) -> pd.Series:
|
|
531
|
+
"""Boolean mask for a half-open time window ``[start, end)``.
|
|
532
|
+
|
|
533
|
+
``window`` is ``(start, end)`` in the time variable's own units; either bound
|
|
534
|
+
may be ``None`` (open). Bounds are coerced to the column's dtype so a date
|
|
535
|
+
bound works against a datetime column and an int bound against a sequence.
|
|
536
|
+
Half-open semantics make a cutpoint partition cleanly: ``(None, c)`` and
|
|
537
|
+
``(c, None)`` are exact complements.
|
|
538
|
+
"""
|
|
539
|
+
start, end = window
|
|
540
|
+
if pd.api.types.is_datetime64_any_dtype(time_col):
|
|
541
|
+
try:
|
|
542
|
+
start = pd.to_datetime(start) if start is not None else None
|
|
543
|
+
end = pd.to_datetime(end) if end is not None else None
|
|
544
|
+
except (ValueError, TypeError) as e:
|
|
545
|
+
raise ValidationError(
|
|
546
|
+
f'window bound is not a valid date for the date time axis: {e}'
|
|
547
|
+
) from e
|
|
548
|
+
mask = pd.Series(True, index=time_col.index)
|
|
549
|
+
try:
|
|
550
|
+
if start is not None:
|
|
551
|
+
mask &= time_col >= start
|
|
552
|
+
if end is not None:
|
|
553
|
+
mask &= time_col < end
|
|
554
|
+
except TypeError as e:
|
|
555
|
+
raise ValidationError(
|
|
556
|
+
f'window bounds {window!r} are not comparable with the '
|
|
557
|
+
f'{time_col.name!r} time axis (dtype {time_col.dtype}); pass an int '
|
|
558
|
+
f'for a sequence axis or a date for a date axis.'
|
|
559
|
+
) from e
|
|
560
|
+
return mask
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def assess_capability(
|
|
564
|
+
source: Study | AnalysisDataSet,
|
|
565
|
+
specs: SpecLimits,
|
|
566
|
+
round_to: int = 3,
|
|
567
|
+
window: tuple | None = None,
|
|
568
|
+
) -> CapabilityResult:
|
|
569
|
+
"""
|
|
570
|
+
Assess process capability against specification limits.
|
|
571
|
+
|
|
572
|
+
This is the main entry point for capability analysis. It accepts
|
|
573
|
+
either a :class:`Study` or an :class:`AnalysisDataSet` and returns
|
|
574
|
+
a :class:`CapabilityResult`.
|
|
575
|
+
|
|
576
|
+
Parameters
|
|
577
|
+
----------
|
|
578
|
+
source : Study | AnalysisDataSet
|
|
579
|
+
Data source containing the response variable (and optionally
|
|
580
|
+
R2 residuals for potential capability).
|
|
581
|
+
specs : SpecLimits
|
|
582
|
+
Specification limits to assess against.
|
|
583
|
+
round_to : int, default 3
|
|
584
|
+
Decimal places for presentation in ``__repr__`` / ``as_dict``.
|
|
585
|
+
window : tuple, optional
|
|
586
|
+
Time-based subset ``(start, end)`` in the study's designated time
|
|
587
|
+
variable's units (int for a sequence, date/datetime for a date axis);
|
|
588
|
+
half-open ``start <= t < end``, either bound ``None`` for open. Default
|
|
589
|
+
``None`` = full dataset (behaviour identical to before this option).
|
|
590
|
+
View-only: the **current** stats and the potential **centering** use the
|
|
591
|
+
windowed observed values, but the potential **noise floor** (σ̂_R2) stays
|
|
592
|
+
the full-study pooled R2 — never re-estimated on the subset.
|
|
593
|
+
|
|
594
|
+
Returns
|
|
595
|
+
-------
|
|
596
|
+
CapabilityResult
|
|
597
|
+
Frozen dataclass with all capability indices.
|
|
598
|
+
|
|
599
|
+
Raises
|
|
600
|
+
------
|
|
601
|
+
ValidationError
|
|
602
|
+
Un-windowed: if fewer than 2 valid observations. Windowed: if the study
|
|
603
|
+
has no time variable, or the window holds fewer than 8 observations.
|
|
604
|
+
"""
|
|
605
|
+
ads = _coerce_ads(source)
|
|
606
|
+
response_var = ads.spec.response_var
|
|
607
|
+
df = ads.analysis_dataset
|
|
608
|
+
|
|
609
|
+
# --- Extract valid Y values (windowed observed view, or full) ---
|
|
610
|
+
window_meta = None
|
|
611
|
+
time_var_meta = None
|
|
612
|
+
n_total = None
|
|
613
|
+
window_warning = None
|
|
614
|
+
|
|
615
|
+
if window is not None:
|
|
616
|
+
time_var = ads.spec.time_var
|
|
617
|
+
if not time_var:
|
|
618
|
+
raise ValidationError('window= requires a designated time variable; this study has none.')
|
|
619
|
+
n_total = int(df[response_var].notna().sum())
|
|
620
|
+
obs_df = df[_time_window_mask(df[time_var], window)]
|
|
621
|
+
y_values = obs_df[response_var].dropna().to_numpy(dtype=float)
|
|
622
|
+
n = len(y_values)
|
|
623
|
+
# Single n-ladder on the windowed path: refuse < 8 subsumes the legacy
|
|
624
|
+
# n < 2 check (and is strictly more informative).
|
|
625
|
+
if n < 8:
|
|
626
|
+
tmin, tmax = df[time_var].min(), df[time_var].max()
|
|
627
|
+
raise ValidationError(
|
|
628
|
+
f'Capability on window {window!r} of {time_var!r} has only {n} '
|
|
629
|
+
f'observation(s); need >= 8 for a meaningful figure. '
|
|
630
|
+
f'{time_var!r} spans [{tmin}, {tmax}].'
|
|
631
|
+
)
|
|
632
|
+
if n < 30:
|
|
633
|
+
window_warning = (
|
|
634
|
+
f'n={n}: capability indices (Ppk/Cpk) are highly unstable at this '
|
|
635
|
+
f'sample size; treat as indicative, not authoritative.'
|
|
636
|
+
)
|
|
637
|
+
window_meta, time_var_meta = tuple(window), time_var
|
|
638
|
+
else:
|
|
639
|
+
y_values = df[response_var].dropna().to_numpy(dtype=float)
|
|
640
|
+
n = len(y_values)
|
|
641
|
+
if n < 2:
|
|
642
|
+
raise ValidationError(f'Capability analysis requires at least 2 valid observations, got {n}.')
|
|
643
|
+
|
|
644
|
+
# --- Current capability ---
|
|
645
|
+
y_bar = float(np.mean(y_values))
|
|
646
|
+
s, sigma_hat = compute_sigma_hat(y_values)
|
|
647
|
+
|
|
648
|
+
current = compute_capability_indices(y_bar, sigma_hat, specs)
|
|
649
|
+
outside = compute_pct_outside(y_values, specs)
|
|
650
|
+
|
|
651
|
+
# --- Potential capability (from R2 residuals) ---
|
|
652
|
+
sigma_hat_r2 = None
|
|
653
|
+
cp = None
|
|
654
|
+
cpk_lower = None
|
|
655
|
+
cpk_upper = None
|
|
656
|
+
cpk = None
|
|
657
|
+
potential_unavailable_reason = None
|
|
658
|
+
potential_values = None
|
|
659
|
+
potential_outside = {}
|
|
660
|
+
|
|
661
|
+
if ads.has_vas_residuals and 'R2' in df.columns:
|
|
662
|
+
r2_values = df['R2'].dropna().to_numpy(dtype=float)
|
|
663
|
+
n_r2 = len(r2_values)
|
|
664
|
+
|
|
665
|
+
if n_r2 >= 2:
|
|
666
|
+
_, sigma_hat_r2 = compute_sigma_hat(r2_values)
|
|
667
|
+
pot = compute_capability_indices(y_bar, sigma_hat_r2, specs)
|
|
668
|
+
cp = pot['pp']
|
|
669
|
+
cpk_lower = pot['ppk_lower']
|
|
670
|
+
cpk_upper = pot['ppk_upper']
|
|
671
|
+
cpk = pot['ppk']
|
|
672
|
+
potential_values = y_bar + r2_values
|
|
673
|
+
potential_outside = compute_pct_outside(potential_values, specs)
|
|
674
|
+
else:
|
|
675
|
+
potential_unavailable_reason = (
|
|
676
|
+
f'Too few R2 residual values ({n_r2}) for potential capability; need at least 2'
|
|
677
|
+
)
|
|
678
|
+
else:
|
|
679
|
+
potential_unavailable_reason = (
|
|
680
|
+
f'R2 residuals not available (ADS {ads.analytical_design_state.sds}); '
|
|
681
|
+
f'Cp/Cpk require VAS residuals from a factorial+time design'
|
|
682
|
+
)
|
|
683
|
+
|
|
684
|
+
return CapabilityResult(
|
|
685
|
+
specs=specs,
|
|
686
|
+
n=n,
|
|
687
|
+
y_bar=y_bar,
|
|
688
|
+
s=s,
|
|
689
|
+
sigma_hat=sigma_hat,
|
|
690
|
+
pp=current['pp'],
|
|
691
|
+
ppk_lower=current['ppk_lower'],
|
|
692
|
+
ppk_upper=current['ppk_upper'],
|
|
693
|
+
ppk=current['ppk'],
|
|
694
|
+
sigma_hat_r2=sigma_hat_r2,
|
|
695
|
+
cp=cp,
|
|
696
|
+
cpk_lower=cpk_lower,
|
|
697
|
+
cpk_upper=cpk_upper,
|
|
698
|
+
cpk=cpk,
|
|
699
|
+
potential_unavailable_reason=potential_unavailable_reason,
|
|
700
|
+
potential_values=potential_values,
|
|
701
|
+
z_lower=current['z_lower'],
|
|
702
|
+
z_upper=current['z_upper'],
|
|
703
|
+
n_below_lsl=outside['n_below_lsl'],
|
|
704
|
+
n_above_usl=outside['n_above_usl'],
|
|
705
|
+
n_outside=outside['n_outside'],
|
|
706
|
+
pct_below_lsl=outside['pct_below_lsl'],
|
|
707
|
+
pct_above_usl=outside['pct_above_usl'],
|
|
708
|
+
pct_outside=outside['pct_outside'],
|
|
709
|
+
potential_n_below_lsl=potential_outside.get('n_below_lsl'),
|
|
710
|
+
potential_n_above_usl=potential_outside.get('n_above_usl'),
|
|
711
|
+
potential_pct_below_lsl=potential_outside.get('pct_below_lsl'),
|
|
712
|
+
potential_pct_above_usl=potential_outside.get('pct_above_usl'),
|
|
713
|
+
response_var=response_var,
|
|
714
|
+
round_to=round_to,
|
|
715
|
+
window=window_meta,
|
|
716
|
+
time_var=time_var_meta,
|
|
717
|
+
n_total=n_total,
|
|
718
|
+
window_warning=window_warning,
|
|
719
|
+
observed_values=y_values,
|
|
720
|
+
)
|