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