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,692 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Statistical Process Control constants and formulas.
|
|
3
|
+
|
|
4
|
+
This module contains domain knowledge for SPC (Statistical Process Control):
|
|
5
|
+
- Control chart constants (c4, b3, b4, d2, d3)
|
|
6
|
+
- Control limit calculations for various chart types
|
|
7
|
+
- Signal detection rules
|
|
8
|
+
|
|
9
|
+
References
|
|
10
|
+
----------
|
|
11
|
+
- Wheeler, D. J. (1995). Advanced Topics in Statistical Process Control
|
|
12
|
+
- ISO 7870-2:2013 Control charts - Part 2: Shewhart control charts
|
|
13
|
+
- Montgomery, D. C. (2009). Statistical Quality Control
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import math
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
import pandas as pd
|
|
22
|
+
|
|
23
|
+
from .exceptions import ValidationError
|
|
24
|
+
|
|
25
|
+
# ============================================================================
|
|
26
|
+
# Statistical Control Chart Constants
|
|
27
|
+
# ============================================================================
|
|
28
|
+
|
|
29
|
+
# Control limit multiplier (3-sigma limits are standard in SPC)
|
|
30
|
+
SIGMA_MULTIPLIER = 3
|
|
31
|
+
|
|
32
|
+
# E2 constant for XmR charts (n=2, moving range of 2 consecutive observations)
|
|
33
|
+
# Used for calculating control limits on individual values
|
|
34
|
+
# E2 = d2 / d3 for n=2, where d2 = 1.128 and d3 = 0.8525
|
|
35
|
+
XMR_LIMIT_MULTIPLIER = 2.66
|
|
36
|
+
|
|
37
|
+
# D4 constant for R charts (n=2, range of 2 consecutive observations)
|
|
38
|
+
# Used for upper control limit on moving range
|
|
39
|
+
# D4 = 1 + 3(d3/d2) for n=2
|
|
40
|
+
R_UPPER_LIMIT_MULTIPLIER = 3.268
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
# ============================================================================
|
|
44
|
+
# Bias Correction Constants
|
|
45
|
+
# ============================================================================
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def c4(n: int) -> float:
|
|
49
|
+
"""
|
|
50
|
+
Calculate c4 bias constant for Xbar and S charts.
|
|
51
|
+
|
|
52
|
+
The c4 constant corrects for bias in the standard deviation estimate
|
|
53
|
+
when using subgroups. It approaches 1.0 as n increases.
|
|
54
|
+
|
|
55
|
+
Parameters
|
|
56
|
+
----------
|
|
57
|
+
n : int
|
|
58
|
+
Subgroup size (must be >= 2)
|
|
59
|
+
|
|
60
|
+
Returns
|
|
61
|
+
-------
|
|
62
|
+
float
|
|
63
|
+
c4 constant for given subgroup size
|
|
64
|
+
|
|
65
|
+
Examples
|
|
66
|
+
--------
|
|
67
|
+
>>> c4(2)
|
|
68
|
+
0.7978845608028654
|
|
69
|
+
>>> c4(5)
|
|
70
|
+
0.9399856029866252
|
|
71
|
+
>>> c4(25)
|
|
72
|
+
0.9896050420253648
|
|
73
|
+
|
|
74
|
+
Notes
|
|
75
|
+
-----
|
|
76
|
+
Formula: c4(n) = sqrt(2/(n-1)) * Γ(n/2) / Γ((n-1)/2)
|
|
77
|
+
|
|
78
|
+
For small samples (n < 2), the standard deviation is heavily biased.
|
|
79
|
+
The c4 constant corrects this bias.
|
|
80
|
+
|
|
81
|
+
References
|
|
82
|
+
----------
|
|
83
|
+
Wheeler (1995), Advanced Topics in SPC, Chapter 3
|
|
84
|
+
"""
|
|
85
|
+
if n < 2:
|
|
86
|
+
raise ValueError(f'Subgroup size must be >= 2, got {n}')
|
|
87
|
+
|
|
88
|
+
out = np.sqrt(2 / (n - 1)) * (np.exp(math.lgamma(n / 2) - math.lgamma((n - 1) / 2)))
|
|
89
|
+
return out
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def b3(n: int, sigma_multiplier: float = 3) -> float:
|
|
93
|
+
"""
|
|
94
|
+
Calculate b3 lower control limit constant for S charts.
|
|
95
|
+
|
|
96
|
+
The b3 constant is used to calculate the lower process limit (LPL)
|
|
97
|
+
for the standard deviation (S) chart.
|
|
98
|
+
|
|
99
|
+
Parameters
|
|
100
|
+
----------
|
|
101
|
+
n : int
|
|
102
|
+
Subgroup size (must be >= 2)
|
|
103
|
+
sigma_multiplier : float, default 3
|
|
104
|
+
Sigma multiplier for control limits (default 3-sigma)
|
|
105
|
+
|
|
106
|
+
Returns
|
|
107
|
+
-------
|
|
108
|
+
float
|
|
109
|
+
b3 constant for given subgroup size. Returns 0 if calculated value < 0.
|
|
110
|
+
|
|
111
|
+
Examples
|
|
112
|
+
--------
|
|
113
|
+
>>> b3(2)
|
|
114
|
+
0
|
|
115
|
+
>>> b3(5)
|
|
116
|
+
0
|
|
117
|
+
>>> b3(10)
|
|
118
|
+
0.284...
|
|
119
|
+
|
|
120
|
+
Notes
|
|
121
|
+
-----
|
|
122
|
+
Formula: b3(n) = 1 - sigma_multiplier/c4(n) * sqrt(1 - c4(n)²)
|
|
123
|
+
|
|
124
|
+
For small subgroup sizes (n < 6), the raw b3 formula yields negative
|
|
125
|
+
values. Since standard deviations cannot be negative, these values are
|
|
126
|
+
clamped to 0.
|
|
127
|
+
|
|
128
|
+
References
|
|
129
|
+
----------
|
|
130
|
+
Wheeler (1995), Advanced Topics in SPC, Chapter 3
|
|
131
|
+
"""
|
|
132
|
+
if n < 2:
|
|
133
|
+
raise ValueError(f'Subgroup size must be >= 2, got {n}')
|
|
134
|
+
|
|
135
|
+
c4_n = c4(n)
|
|
136
|
+
out = 1 - (sigma_multiplier / c4_n * math.sqrt(1 - math.pow(c4_n, 2)))
|
|
137
|
+
|
|
138
|
+
return 0 if out < 0 else out
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def b4(n: int, sigma_multiplier: float = 3) -> float:
|
|
142
|
+
"""
|
|
143
|
+
Calculate b4 upper control limit constant for S charts.
|
|
144
|
+
|
|
145
|
+
The b4 constant is used to calculate the upper process limit (UPL)
|
|
146
|
+
for the standard deviation (S) chart.
|
|
147
|
+
|
|
148
|
+
Parameters
|
|
149
|
+
----------
|
|
150
|
+
n : int
|
|
151
|
+
Subgroup size (must be >= 2)
|
|
152
|
+
sigma_multiplier : float, default 3
|
|
153
|
+
Sigma multiplier for control limits (default 3-sigma)
|
|
154
|
+
|
|
155
|
+
Returns
|
|
156
|
+
-------
|
|
157
|
+
float
|
|
158
|
+
b4 constant for given subgroup size
|
|
159
|
+
|
|
160
|
+
Examples
|
|
161
|
+
--------
|
|
162
|
+
>>> b4(2)
|
|
163
|
+
3.2667284695806667
|
|
164
|
+
>>> b4(5)
|
|
165
|
+
2.088586704330583
|
|
166
|
+
>>> b4(25)
|
|
167
|
+
1.4355113780646384
|
|
168
|
+
|
|
169
|
+
Notes
|
|
170
|
+
-----
|
|
171
|
+
Formula: b4(n) = 1 + sigma_multiplier/(c4(n)) * sqrt(1 - c4(n)^2)
|
|
172
|
+
|
|
173
|
+
The b4 constant decreases as subgroup size increases, approaching the
|
|
174
|
+
value of 1 + 3*sqrt(1-1) = 1 for very large subgroups.
|
|
175
|
+
|
|
176
|
+
References
|
|
177
|
+
----------
|
|
178
|
+
Wheeler (1995), Advanced Topics in SPC, Chapter 3
|
|
179
|
+
"""
|
|
180
|
+
if n < 2:
|
|
181
|
+
raise ValueError(f'Subgroup size must be >= 2, got {n}')
|
|
182
|
+
|
|
183
|
+
c4_n = c4(n)
|
|
184
|
+
out = 1 + (sigma_multiplier / c4_n * math.sqrt(1 - math.pow(c4_n, 2)))
|
|
185
|
+
|
|
186
|
+
return out
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
# ============================================================================
|
|
190
|
+
# Control Limit Calculations
|
|
191
|
+
# ============================================================================
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def calculate_limits(
|
|
195
|
+
limits_type: str,
|
|
196
|
+
mean: float | None = None,
|
|
197
|
+
sd: float | None = None,
|
|
198
|
+
N: int | None = None,
|
|
199
|
+
mR: float | None = None,
|
|
200
|
+
round_to: int = 3,
|
|
201
|
+
sigma_multiplier: float = 3,
|
|
202
|
+
) -> pd.Series:
|
|
203
|
+
"""
|
|
204
|
+
Calculate control limits for various chart types.
|
|
205
|
+
|
|
206
|
+
Supports Xbar, S, XmR (individuals/moving range), and R charts.
|
|
207
|
+
|
|
208
|
+
Parameters
|
|
209
|
+
----------
|
|
210
|
+
limits_type : str
|
|
211
|
+
Type of control chart: 'Xbar', 'S', 'XmR', or 'R'
|
|
212
|
+
mean : float, optional
|
|
213
|
+
Grand mean or subgroup mean (required for Xbar, XmR)
|
|
214
|
+
sd : float, optional
|
|
215
|
+
Standard deviation (required for Xbar, S)
|
|
216
|
+
N : int, optional
|
|
217
|
+
Subgroup size (required for Xbar, S)
|
|
218
|
+
mR : float, optional
|
|
219
|
+
Mean moving range (required for XmR, R)
|
|
220
|
+
round_to : int, default=3
|
|
221
|
+
Number of decimal places for rounding (currently unused)
|
|
222
|
+
|
|
223
|
+
Returns
|
|
224
|
+
-------
|
|
225
|
+
pd.Series
|
|
226
|
+
Series with 'lpl' and 'upl' keys (Lower/Upper Process Limits)
|
|
227
|
+
|
|
228
|
+
Examples
|
|
229
|
+
--------
|
|
230
|
+
Calculate Xbar chart limits:
|
|
231
|
+
>>> calculate_limits(limits_type='Xbar', mean=10.0, sd=0.5, N=5)
|
|
232
|
+
lpl 9.366...
|
|
233
|
+
upl 10.633...
|
|
234
|
+
dtype: float64
|
|
235
|
+
|
|
236
|
+
Calculate S chart limits:
|
|
237
|
+
>>> calculate_limits(limits_type='S', sd=0.5, N=5)
|
|
238
|
+
lpl 0.0
|
|
239
|
+
upl 1.044...
|
|
240
|
+
dtype: float64
|
|
241
|
+
|
|
242
|
+
Calculate XmR chart limits:
|
|
243
|
+
>>> calculate_limits(limits_type='XmR', mean=10.0, mR=0.3)
|
|
244
|
+
lpl 9.202
|
|
245
|
+
upl 10.798
|
|
246
|
+
dtype: float64
|
|
247
|
+
|
|
248
|
+
Calculate R chart limits:
|
|
249
|
+
>>> calculate_limits(limits_type='R', mR=0.3)
|
|
250
|
+
lpl 0.000
|
|
251
|
+
upl 0.980...
|
|
252
|
+
dtype: float64
|
|
253
|
+
|
|
254
|
+
Raises
|
|
255
|
+
------
|
|
256
|
+
ValueError
|
|
257
|
+
If required parameters are missing or limits_type is invalid
|
|
258
|
+
|
|
259
|
+
Notes
|
|
260
|
+
-----
|
|
261
|
+
Xbar limits: X̄ ± (3 * Wd) / sqrt(n), where Wd = S / c4(n)
|
|
262
|
+
S limits: S * b3(n) to S * b4(n)
|
|
263
|
+
XmR limits: X̄ ± (E2 * mR), where E2 = 2.66
|
|
264
|
+
R limits: 0 to mR * D4, where D4 = 3.268
|
|
265
|
+
|
|
266
|
+
References
|
|
267
|
+
----------
|
|
268
|
+
Wheeler (1995), Advanced Topics in SPC, Chapters 3-5
|
|
269
|
+
"""
|
|
270
|
+
if limits_type == 'Xbar':
|
|
271
|
+
if None in [sd, mean, N]:
|
|
272
|
+
raise ValueError(
|
|
273
|
+
f'The limits calculation for {limits_type} requires (mean, sd, and N). Got: mean={mean}, sd={sd}, N={N}'
|
|
274
|
+
)
|
|
275
|
+
assert mean is not None and sd is not None and N is not None # narrowed by guard above # noqa: S101
|
|
276
|
+
|
|
277
|
+
# Wd = S / c4(n) - within-subgroup standard deviation
|
|
278
|
+
Wd = sd / c4(N)
|
|
279
|
+
|
|
280
|
+
# Studentized Process Limits for sub-group means
|
|
281
|
+
# LPLx = X̄ - (sigma_multiplier * Wd) / √n
|
|
282
|
+
# UPLx = X̄ + (sigma_multiplier * Wd) / √n
|
|
283
|
+
lpl = mean - ((sigma_multiplier * Wd) / math.sqrt(N))
|
|
284
|
+
upl = mean + ((sigma_multiplier * Wd) / math.sqrt(N))
|
|
285
|
+
|
|
286
|
+
elif limits_type == 'S':
|
|
287
|
+
if None in [sd, N]:
|
|
288
|
+
raise ValueError(f'The limits calculation for {limits_type} requires (sd, and N). Got: sd={sd}, N={N}')
|
|
289
|
+
assert sd is not None and N is not None # narrowed by guard above # noqa: S101
|
|
290
|
+
|
|
291
|
+
# LPL = S * b3(N)
|
|
292
|
+
# UPL = S * b4(N)
|
|
293
|
+
lpl = sd * b3(N, sigma_multiplier)
|
|
294
|
+
upl = sd * b4(N, sigma_multiplier)
|
|
295
|
+
|
|
296
|
+
elif limits_type == 'XmR':
|
|
297
|
+
if None in [mean, mR]:
|
|
298
|
+
raise ValueError(
|
|
299
|
+
f'The limits calculation for {limits_type} requires (mean, and mR). Got: mean={mean}, mR={mR}'
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
# LPL = X̄ - (E2 * mR)
|
|
303
|
+
# UPL = X̄ + (E2 * mR)
|
|
304
|
+
lpl = mean - (XMR_LIMIT_MULTIPLIER * mR)
|
|
305
|
+
upl = mean + (XMR_LIMIT_MULTIPLIER * mR)
|
|
306
|
+
|
|
307
|
+
elif limits_type == 'R':
|
|
308
|
+
if mR is None:
|
|
309
|
+
raise ValueError(f'The limits calculation for {limits_type} requires (mR). Got: mR={mR}')
|
|
310
|
+
|
|
311
|
+
# LPL = 0 (ranges cannot be negative)
|
|
312
|
+
# UPL = mR * D4
|
|
313
|
+
lpl = 0
|
|
314
|
+
upl = mR * R_UPPER_LIMIT_MULTIPLIER
|
|
315
|
+
|
|
316
|
+
else:
|
|
317
|
+
raise ValueError(f"The limits type '{limits_type}' is not supported. Supported types: 'Xbar', 'S', 'XmR', 'R'")
|
|
318
|
+
|
|
319
|
+
return pd.Series({'lpl': lpl, 'upl': upl}, index=['lpl', 'upl'])
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def calculate_limits_vectorized(
|
|
323
|
+
limits_type: str,
|
|
324
|
+
*,
|
|
325
|
+
mean=None,
|
|
326
|
+
sd=None,
|
|
327
|
+
N=None,
|
|
328
|
+
mR=None,
|
|
329
|
+
sigma_multiplier: float = 3,
|
|
330
|
+
) -> pd.DataFrame:
|
|
331
|
+
"""Array form of :func:`calculate_limits` — same formulae, whole columns at once.
|
|
332
|
+
|
|
333
|
+
:func:`calculate_limits` stays the scalar reference and is unchanged: it is what the
|
|
334
|
+
Bishop validator exercises, so keeping it independent means the 280 reference
|
|
335
|
+
assertions remain a genuine check on this path rather than a check of it against
|
|
336
|
+
itself.
|
|
337
|
+
|
|
338
|
+
Why this exists: the chart builders called the scalar form once per subgroup through
|
|
339
|
+
``DataFrame.apply(axis=1)``, each call constructing a ``pd.Series`` to carry two
|
|
340
|
+
numbers. At 1M rows (~50,000 subgroups) that was ~2.5s per chart; this is ~0.001s.
|
|
341
|
+
|
|
342
|
+
``mean``, ``sd``, ``N`` and ``mR`` accept scalars or array-likes and broadcast.
|
|
343
|
+
Subgroup-size constants (c4/b3/b4) are looked up once per *distinct* N and mapped, so
|
|
344
|
+
an N of 1 still raises ``ValueError`` from :func:`c4` exactly as the scalar path does.
|
|
345
|
+
|
|
346
|
+
Returns
|
|
347
|
+
-------
|
|
348
|
+
pd.DataFrame
|
|
349
|
+
Columns ``lpl`` and ``upl``. No rounding is applied — matching the scalar form,
|
|
350
|
+
whose ``round_to`` parameter is documented as unused. The index is taken from the
|
|
351
|
+
first pandas input, so ``df[['lpl', 'upl']] = calculate_limits_vectorized(...)``
|
|
352
|
+
aligns instead of silently producing NaN on a non-default index.
|
|
353
|
+
|
|
354
|
+
Notes
|
|
355
|
+
-----
|
|
356
|
+
**Conversion is deliberately partial.** The four primary Xbar/S limit computations in
|
|
357
|
+
``analysis.py`` use this; the phase-segment and per-stratum sites still call the scalar
|
|
358
|
+
form. Those iterate over phases or strata (a handful of rows), not subgroups, so they
|
|
359
|
+
were not hot when measured — converting them would carry risk for no gain. If a future
|
|
360
|
+
profile shows otherwise, they are the next candidates; do not assume the absence of a
|
|
361
|
+
call here means a site was checked and rejected on correctness grounds.
|
|
362
|
+
"""
|
|
363
|
+
index = next((arg.index for arg in (mean, sd, N, mR) if isinstance(arg, pd.Series)), None)
|
|
364
|
+
|
|
365
|
+
def _per_n(func, sizes, *args):
|
|
366
|
+
"""Evaluate a subgroup-size constant once per distinct N, then broadcast."""
|
|
367
|
+
sizes = np.asarray(sizes)
|
|
368
|
+
lookup = {int(v): func(int(v), *args) for v in np.unique(sizes)}
|
|
369
|
+
return np.array([lookup[int(v)] for v in sizes.ravel()]).reshape(sizes.shape)
|
|
370
|
+
|
|
371
|
+
if limits_type == 'Xbar':
|
|
372
|
+
if mean is None or sd is None or N is None:
|
|
373
|
+
raise ValueError(
|
|
374
|
+
f'The limits calculation for {limits_type} requires (mean, sd, and N). '
|
|
375
|
+
f'Got: mean={mean}, sd={sd}, N={N}'
|
|
376
|
+
)
|
|
377
|
+
sizes = np.asarray(N)
|
|
378
|
+
# Wd = S / c4(n), then half-width = (multiplier * Wd) / sqrt(n)
|
|
379
|
+
half = (sigma_multiplier * (np.asarray(sd) / _per_n(c4, sizes))) / np.sqrt(sizes)
|
|
380
|
+
lpl, upl = np.asarray(mean) - half, np.asarray(mean) + half
|
|
381
|
+
|
|
382
|
+
elif limits_type == 'S':
|
|
383
|
+
if sd is None or N is None:
|
|
384
|
+
raise ValueError(
|
|
385
|
+
f'The limits calculation for {limits_type} requires (sd, and N). Got: sd={sd}, N={N}'
|
|
386
|
+
)
|
|
387
|
+
sizes = np.asarray(N)
|
|
388
|
+
lpl = np.asarray(sd) * _per_n(b3, sizes, sigma_multiplier)
|
|
389
|
+
upl = np.asarray(sd) * _per_n(b4, sizes, sigma_multiplier)
|
|
390
|
+
|
|
391
|
+
elif limits_type == 'XmR':
|
|
392
|
+
if mean is None or mR is None:
|
|
393
|
+
raise ValueError(
|
|
394
|
+
f'The limits calculation for {limits_type} requires (mean, and mR). '
|
|
395
|
+
f'Got: mean={mean}, mR={mR}'
|
|
396
|
+
)
|
|
397
|
+
half = XMR_LIMIT_MULTIPLIER * np.asarray(mR)
|
|
398
|
+
lpl, upl = np.asarray(mean) - half, np.asarray(mean) + half
|
|
399
|
+
|
|
400
|
+
elif limits_type == 'R':
|
|
401
|
+
if mR is None:
|
|
402
|
+
raise ValueError(f'The limits calculation for {limits_type} requires (mR). Got: mR={mR}')
|
|
403
|
+
upl = np.asarray(mR) * R_UPPER_LIMIT_MULTIPLIER
|
|
404
|
+
lpl = np.zeros_like(upl)
|
|
405
|
+
|
|
406
|
+
else:
|
|
407
|
+
raise ValueError(
|
|
408
|
+
f"The limits type '{limits_type}' is not supported. Supported types: 'Xbar', 'S', 'XmR', 'R'"
|
|
409
|
+
)
|
|
410
|
+
|
|
411
|
+
lpl, upl = np.asarray(lpl), np.asarray(upl)
|
|
412
|
+
if lpl.ndim == 0: # all-scalar inputs still return a one-row frame
|
|
413
|
+
lpl, upl = lpl.reshape(1), upl.reshape(1)
|
|
414
|
+
return pd.DataFrame({'lpl': lpl, 'upl': upl}, index=index)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# d2 bias constant for the n=2 moving range, derived from the library's own E2
|
|
418
|
+
# (E2 = sigma_multiplier / d2 at n=2, with the default 3-sigma multiplier). Used
|
|
419
|
+
# only on the calibration path so calibrated X/mR limits stay internally
|
|
420
|
+
# consistent with the data-path XmR/R constants.
|
|
421
|
+
D2_N2 = 3.0 / XMR_LIMIT_MULTIPLIER
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def calibrated_limits(
|
|
425
|
+
limits_type: str,
|
|
426
|
+
*,
|
|
427
|
+
mean: float,
|
|
428
|
+
sigma: float,
|
|
429
|
+
N: int | None = None,
|
|
430
|
+
n_sigma: float = 3.0,
|
|
431
|
+
center_zero: bool = False,
|
|
432
|
+
round_to: int = 3,
|
|
433
|
+
) -> tuple[float, pd.Series]:
|
|
434
|
+
"""Standards-given control limits from a known ``(mean, sigma)``.
|
|
435
|
+
|
|
436
|
+
The calibration is applied **forward** from a known *individual* sigma — the
|
|
437
|
+
sigma is used as-is, never run back through c4/d2/b3/b4 to "recover" a
|
|
438
|
+
process sigma. To guarantee the calibrated limits round and clamp exactly
|
|
439
|
+
like the data path, this reuses :func:`calculate_limits` by injecting a
|
|
440
|
+
sigma-scaled input that makes the existing formula emit the standards-given
|
|
441
|
+
band:
|
|
442
|
+
|
|
443
|
+
- **Xbar** (location): inject ``sd = c4(N)·sigma`` → ``mean ± n_sigma·sigma/√N``
|
|
444
|
+
(the ``c4`` cancels); center ``= mean``.
|
|
445
|
+
- **S** (dispersion): inject ``sd = c4(N)·sigma`` → ``b3·(c4·sigma) = B5·sigma``,
|
|
446
|
+
``b4·(c4·sigma) = B6·sigma``; center ``= c4(N)·sigma``.
|
|
447
|
+
- **XmR** (X individuals, location): inject ``mR = D2_N2·sigma`` →
|
|
448
|
+
``mean ± E2·(3/E2)·sigma = mean ± 3·sigma`` (fixed 3-sigma); center ``= mean``.
|
|
449
|
+
- **R** (mR companion, dispersion): inject ``mR = D2_N2·sigma`` →
|
|
450
|
+
``0 … D4·(d2·sigma)``; center ``= d2·sigma``.
|
|
451
|
+
|
|
452
|
+
Parameters
|
|
453
|
+
----------
|
|
454
|
+
limits_type : str
|
|
455
|
+
'Xbar', 'S', 'XmR', or 'R'.
|
|
456
|
+
mean : float
|
|
457
|
+
Calibrated center for location charts (ignored when ``center_zero``).
|
|
458
|
+
sigma : float
|
|
459
|
+
Known within-subgroup standard deviation of individual values (> 0).
|
|
460
|
+
N : int, optional
|
|
461
|
+
Subgroup size (required for Xbar/S).
|
|
462
|
+
n_sigma : float, default 3.0
|
|
463
|
+
Width multiplier; composes for Xbar/S. Ignored by XmR/R (fixed 3-sigma).
|
|
464
|
+
center_zero : bool, default False
|
|
465
|
+
Force center to 0 (plain residual charts, whose mean is meaningless).
|
|
466
|
+
round_to : int
|
|
467
|
+
Decimal places, forwarded to :func:`calculate_limits`.
|
|
468
|
+
|
|
469
|
+
Returns
|
|
470
|
+
-------
|
|
471
|
+
(center, limits) : tuple[float, pandas.Series]
|
|
472
|
+
``center`` is the calibrated center line; ``limits`` has 'lpl'/'upl'.
|
|
473
|
+
"""
|
|
474
|
+
if center_zero:
|
|
475
|
+
mean = 0.0
|
|
476
|
+
|
|
477
|
+
if limits_type in ('Xbar', 'S') and N is None:
|
|
478
|
+
raise ValueError(f"calibrated_limits requires N for limits_type {limits_type!r}.")
|
|
479
|
+
|
|
480
|
+
if limits_type == 'Xbar':
|
|
481
|
+
assert N is not None
|
|
482
|
+
center = mean
|
|
483
|
+
lims = calculate_limits(
|
|
484
|
+
limits_type='Xbar', mean=mean, sd=c4(N) * sigma, N=N,
|
|
485
|
+
round_to=round_to, sigma_multiplier=n_sigma,
|
|
486
|
+
)
|
|
487
|
+
elif limits_type == 'S':
|
|
488
|
+
assert N is not None
|
|
489
|
+
center = c4(N) * sigma
|
|
490
|
+
lims = calculate_limits(
|
|
491
|
+
limits_type='S', mean=0, sd=c4(N) * sigma, N=N,
|
|
492
|
+
round_to=round_to, sigma_multiplier=n_sigma,
|
|
493
|
+
)
|
|
494
|
+
elif limits_type == 'XmR':
|
|
495
|
+
center = mean
|
|
496
|
+
lims = calculate_limits(
|
|
497
|
+
limits_type='XmR', mean=mean, sd=0, N=0, mR=D2_N2 * sigma,
|
|
498
|
+
round_to=round_to,
|
|
499
|
+
)
|
|
500
|
+
elif limits_type == 'R':
|
|
501
|
+
center = D2_N2 * sigma
|
|
502
|
+
lims = calculate_limits(
|
|
503
|
+
limits_type='R', mean=0, sd=0, N=0, mR=D2_N2 * sigma,
|
|
504
|
+
round_to=round_to,
|
|
505
|
+
)
|
|
506
|
+
else:
|
|
507
|
+
raise ValueError(
|
|
508
|
+
f"calibrated_limits does not support limits_type {limits_type!r}; "
|
|
509
|
+
"expected 'Xbar', 'S', 'XmR', or 'R'."
|
|
510
|
+
)
|
|
511
|
+
return center, lims
|
|
512
|
+
|
|
513
|
+
|
|
514
|
+
# ============================================================================
|
|
515
|
+
# Signal Detection
|
|
516
|
+
# ============================================================================
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
def detect_beyond_limits(x: float, lpl: float, upl: float) -> int:
|
|
520
|
+
"""
|
|
521
|
+
Detect if a point is beyond process limits.
|
|
522
|
+
|
|
523
|
+
This is the most basic signal detection rule (Rule 1 in Western Electric
|
|
524
|
+
rules): a single point beyond the 3-sigma process limits.
|
|
525
|
+
|
|
526
|
+
Parameters
|
|
527
|
+
----------
|
|
528
|
+
x : float
|
|
529
|
+
Value to check
|
|
530
|
+
lpl : float
|
|
531
|
+
Lower process limit
|
|
532
|
+
upl : float
|
|
533
|
+
Upper process limit
|
|
534
|
+
|
|
535
|
+
Returns
|
|
536
|
+
-------
|
|
537
|
+
int
|
|
538
|
+
-1 if below LPL, 1 if above UPL, 0 if within limits
|
|
539
|
+
|
|
540
|
+
Examples
|
|
541
|
+
--------
|
|
542
|
+
>>> detect_beyond_limits(10.5, lpl=9.0, upl=11.0)
|
|
543
|
+
0
|
|
544
|
+
>>> detect_beyond_limits(8.5, lpl=9.0, upl=11.0)
|
|
545
|
+
-1
|
|
546
|
+
>>> detect_beyond_limits(11.5, lpl=9.0, upl=11.0)
|
|
547
|
+
1
|
|
548
|
+
|
|
549
|
+
Notes
|
|
550
|
+
-----
|
|
551
|
+
This implements "Test 1" from Western Electric rules:
|
|
552
|
+
- One point beyond the 3-sigma process limits
|
|
553
|
+
|
|
554
|
+
Additional tests (runs, trends, etc.) are not implemented here.
|
|
555
|
+
|
|
556
|
+
References
|
|
557
|
+
----------
|
|
558
|
+
Western Electric (1956). Statistical Quality Control Handbook
|
|
559
|
+
Wheeler & Chambers (1992). Understanding Statistical Process Control
|
|
560
|
+
"""
|
|
561
|
+
if x < lpl:
|
|
562
|
+
return -1
|
|
563
|
+
elif x > upl:
|
|
564
|
+
return 1
|
|
565
|
+
else:
|
|
566
|
+
return 0
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ============================================================================
|
|
570
|
+
# Chart Name Constants
|
|
571
|
+
# ============================================================================
|
|
572
|
+
|
|
573
|
+
# Valid base chart types for syntactic validation.
|
|
574
|
+
# API uses focal-chart naming: 'X' (individual) and 'mR' (moving range).
|
|
575
|
+
# Use companion=True to get the paired chart (X↔mR).
|
|
576
|
+
VALID_BASE_CHARTS = {'Xbar', 'S', 'X', 'mR', 'Histogram'}
|
|
577
|
+
|
|
578
|
+
# Case-insensitive canonical chart name mapping
|
|
579
|
+
CHART_NAME_CANONICAL: dict[str, str] = {name.lower(): name for name in VALID_BASE_CHARTS}
|
|
580
|
+
|
|
581
|
+
# The two kinds of VAS residual. They differ in a way that matters to callers:
|
|
582
|
+
#
|
|
583
|
+
# STORED — computed once during formulate() and stored as columns on the analysis
|
|
584
|
+
# dataset. Independent of the execute() request: R5 is the same series
|
|
585
|
+
# whatever `by=` you pass.
|
|
586
|
+
# REQUEST — computed during execute() from the request's `by=`, and materialised only
|
|
587
|
+
# into that result's dataset. R6 with by=['A'] and R6 with by=['B'] are
|
|
588
|
+
# different series, so there is no canonical study-level R6.
|
|
589
|
+
#
|
|
590
|
+
# An enumeration that omits R6 is correct iff it means STORED. Say which you mean.
|
|
591
|
+
# (`derived` is deliberately not used here — derivations.py owns that word for
|
|
592
|
+
# transform/binning specs.)
|
|
593
|
+
STORED_RESIDUALS = ('R1', 'R2', 'R3', 'R4', 'R5')
|
|
594
|
+
REQUEST_RESIDUALS = ('R6',)
|
|
595
|
+
ALL_RESIDUALS = STORED_RESIDUALS + REQUEST_RESIDUALS
|
|
596
|
+
|
|
597
|
+
# Names an analyst reasonably types that this library does not use, each with the
|
|
598
|
+
# spelling to use instead. These are redirects, not silent aliases: 'XmR' and 'IMR'
|
|
599
|
+
# name a chart *pair*, so mapping them to one chart would guess which half was
|
|
600
|
+
# wanted. The Minitab spellings are here because the README teaches the
|
|
601
|
+
# equivalence (XmR = IMR) and an analyst arriving from Minitab types what they
|
|
602
|
+
# know. Keys are lowercase; a stratum that happens to be named one of these is
|
|
603
|
+
# not addressable by name, the same tradeoff already made for 'r'.
|
|
604
|
+
_CHART_ALIAS_GUIDANCE: dict[str, str] = {
|
|
605
|
+
'xmr': "Use chart='X' for the individual chart, or chart='X' with companion=True for both X and mR.",
|
|
606
|
+
'r': "Use chart='mR' for the moving range chart.",
|
|
607
|
+
'imr': "Use chart='X' for the individual chart, or chart='X' with companion=True for both X and mR.",
|
|
608
|
+
'i-mr': "Use chart='X' for the individual chart, or chart='X' with companion=True for both X and mR.",
|
|
609
|
+
'i': "Use chart='X' for the individual chart.",
|
|
610
|
+
'individuals': "Use chart='X' for the individual chart.",
|
|
611
|
+
'xbar-s': "Execute chart='Xbar' and chart='S' separately, or chart='Xbar' with companion=True for both.",
|
|
612
|
+
'xbars': "Execute chart='Xbar' and chart='S' separately, or chart='Xbar' with companion=True for both.",
|
|
613
|
+
'x-bar': "Use chart='Xbar' for the subgroup-mean chart.",
|
|
614
|
+
'mr-chart': "Use chart='mR' for the moving range chart.",
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
|
|
618
|
+
def normalize_chart_name(name: str) -> str:
|
|
619
|
+
"""Normalize chart name to canonical form (case-insensitive).
|
|
620
|
+
|
|
621
|
+
Returns the canonical name if recognized, otherwise returns input unchanged.
|
|
622
|
+
This allows stratum names (e.g. 'Alice') to pass through unmodified — which
|
|
623
|
+
is why there is deliberately no did-you-mean guessing here. Suggestions
|
|
624
|
+
belong at the call sites that know a chart name was meant; this function
|
|
625
|
+
cannot tell a typo from a stratum.
|
|
626
|
+
|
|
627
|
+
Raises
|
|
628
|
+
------
|
|
629
|
+
ValidationError
|
|
630
|
+
If the name is one this library does not use but an analyst plausibly
|
|
631
|
+
types (e.g. 'XmR', 'IMR', 'Xbar-S'), carrying the spelling to use.
|
|
632
|
+
"""
|
|
633
|
+
lower = name.lower()
|
|
634
|
+
if lower in CHART_NAME_CANONICAL:
|
|
635
|
+
return CHART_NAME_CANONICAL[lower]
|
|
636
|
+
if lower in _CHART_ALIAS_GUIDANCE:
|
|
637
|
+
raise ValidationError(f"Chart name '{name}' is not used by this library. {_CHART_ALIAS_GUIDANCE[lower]}")
|
|
638
|
+
return name
|
|
639
|
+
|
|
640
|
+
|
|
641
|
+
def suggest_chart_name(name: str) -> str:
|
|
642
|
+
"""A ``Did you mean 'Xbar'?`` clause for an unrecognised chart name, or ''.
|
|
643
|
+
|
|
644
|
+
For the raise sites that already know a chart name was intended. Matches
|
|
645
|
+
against the canonical names only — alias redirects are handled by
|
|
646
|
+
:func:`normalize_chart_name` before anything reaches here.
|
|
647
|
+
"""
|
|
648
|
+
import difflib
|
|
649
|
+
|
|
650
|
+
close = difflib.get_close_matches(name.lower(), list(CHART_NAME_CANONICAL), n=1, cutoff=0.6)
|
|
651
|
+
if not close:
|
|
652
|
+
return ''
|
|
653
|
+
return f" Did you mean '{CHART_NAME_CANONICAL[close[0]]}'?"
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# The one place a residual gets a human name. Chart titles (plotting.plotter),
|
|
657
|
+
# error messages, and docs all read from here — a second copy is how R5 came to be
|
|
658
|
+
# labelled "Noise / Unexplained variation", which is R2's meaning, in a table that
|
|
659
|
+
# also mapped `within_cell` to R2 and so contradicted itself.
|
|
660
|
+
RESIDUAL_LABELS: dict[str, str] = {
|
|
661
|
+
# Bishop 13.1, "Centering the Original PM Data at 0": R1 = Y_ktn - Ybar.., the
|
|
662
|
+
# response re-expressed as +/- about zero. Not a within-subgroup quantity.
|
|
663
|
+
'R1': 'Response Centered at 0',
|
|
664
|
+
'R2': 'Within-Cell',
|
|
665
|
+
'R3': 'Interaction',
|
|
666
|
+
'R4': 'Time Main Effects',
|
|
667
|
+
'R5': 'Design Condition Main Effects',
|
|
668
|
+
'R6': 'Design Factor Main Effects',
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
# Spellings accepted in place of a residual code, purely so an error message can say
|
|
672
|
+
# "'noise' means R2" instead of "unknown chart". Several spellings may share a code;
|
|
673
|
+
# there is deliberately no reverse map, because "the" alias for a code is not defined.
|
|
674
|
+
#
|
|
675
|
+
# `noise` maps to **R2**. It previously mapped to R5 — design-condition main effects —
|
|
676
|
+
# which is the opposite of what the word means here and of how every doc uses it
|
|
677
|
+
# ("within-cell noise", "the noise floor", "irreducible noise (R2)"). Nothing outside
|
|
678
|
+
# this file consumed the mapping's labels, so the wrong answer was only ever visible
|
|
679
|
+
# to someone reading the source, or to a user who typed `noise` and was pointed at
|
|
680
|
+
# factor effects.
|
|
681
|
+
RESIDUAL_ALIASES: dict[str, str] = {
|
|
682
|
+
'response_centered': 'R1',
|
|
683
|
+
'mean_removed': 'R1',
|
|
684
|
+
'within_cell': 'R2',
|
|
685
|
+
'noise': 'R2',
|
|
686
|
+
'interaction': 'R3',
|
|
687
|
+
'structure_removed': 'R3',
|
|
688
|
+
'time_main_effects': 'R4',
|
|
689
|
+
'time_structure_removed': 'R4',
|
|
690
|
+
'design_condition_main_effects': 'R5',
|
|
691
|
+
'design_factor_main_effects': 'R6',
|
|
692
|
+
}
|