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