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,858 @@
1
+ """
2
+ Process Capability visualization chart.
3
+
4
+ Produces a Plotly histogram with specification limit lines, natural process
5
+ limit (NPL) overlay, capability index annotation box, and out-of-spec shading.
6
+
7
+ Supports ``view='current'`` (default), ``view='potential'`` (NPLs from R2 σ̂),
8
+ and ``paired=True`` (two-panel facet: Current | Potential).
9
+
10
+ Follows the ``effects_charts.py`` pattern: standalone function, takes theme,
11
+ returns ``go.Figure``.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import logging
17
+ import warnings
18
+ from collections.abc import Sequence
19
+ from typing import TYPE_CHECKING
20
+
21
+ import numpy as np
22
+ import plotly.graph_objects as go
23
+
24
+ if TYPE_CHECKING:
25
+ import pandas as pd
26
+
27
+ from ..capability import CapabilityResult, SpecLimits
28
+ from .themes import ChartTheme
29
+
30
+ logger = logging.getLogger(__name__)
31
+
32
+ # ---------------------------------------------------------------------------
33
+ # Local constants — spec-specific colors (NOT reused from control limits)
34
+ # ---------------------------------------------------------------------------
35
+
36
+ _SPEC_LINE_COLOR = '#DC143C' # Crimson — distinct from control limit colors
37
+ _SPEC_SHADE_OPACITY = 0.10
38
+
39
+
40
+ # ---------------------------------------------------------------------------
41
+ # Public API
42
+ # ---------------------------------------------------------------------------
43
+
44
+
45
+ def create_capability_chart(
46
+ cap: CapabilityResult,
47
+ values: Sequence[float] | np.ndarray | pd.Series,
48
+ *,
49
+ theme: ChartTheme | None = None,
50
+ show_potential: bool = True,
51
+ view: str = 'current',
52
+ paired: bool = False,
53
+ x_label: str | None = None,
54
+ nbins: int | None = None,
55
+ histnorm: str = '',
56
+ width: int = 900,
57
+ height: int = 500,
58
+ title: str | None = None,
59
+ ) -> go.Figure:
60
+ """
61
+ Create a process capability histogram with spec limits and index annotations.
62
+
63
+ Parameters
64
+ ----------
65
+ cap : CapabilityResult
66
+ Result from ``study.capability()`` or ``assess_capability()``.
67
+ values : array-like
68
+ Response values to histogram. NaN values are dropped silently.
69
+ theme : ChartTheme, optional
70
+ Visual theme. Defaults to the ``processbehavior`` theme.
71
+ show_potential : bool, default True
72
+ Show Cp/Cpk in the annotation box (when available).
73
+ view : str, default ``'current'``
74
+ Which capability view to plot: ``'current'`` (NPLs from overall σ̂,
75
+ Pp/Ppk annotation) or ``'potential'`` (NPLs from R2 σ̂_R2,
76
+ Cp/Cpk annotation).
77
+ paired : bool, default False
78
+ When ``True``, create a two-panel facet with Current on the left
79
+ and Potential on the right.
80
+ x_label : str, optional
81
+ X-axis label. Defaults to ``"Value"``.
82
+ nbins : int, optional
83
+ Number of histogram bins. ``None`` lets Plotly auto-select.
84
+ histnorm : str, default ""
85
+ Histogram normalization (``""`` for counts, ``"probability density"``
86
+ for density).
87
+ width : int, default 900
88
+ Figure width in pixels.
89
+ height : int, default 500
90
+ Figure height in pixels.
91
+ title : str, optional
92
+ Chart title. Auto-generated from spec limits when ``None``.
93
+
94
+ Returns
95
+ -------
96
+ go.Figure
97
+ """
98
+ from ..exceptions import ValidationError
99
+ from .themes import get_theme
100
+
101
+ # --- Validation ---
102
+ if view not in ('current', 'potential'):
103
+ raise ValidationError(f"view must be 'current' or 'potential', got {view!r}")
104
+
105
+ if view == 'potential' and cap.sigma_hat_r2 is None:
106
+ raise ValidationError(f'Cannot plot potential capability: {cap.potential_unavailable_reason}')
107
+
108
+ if paired and cap.sigma_hat_r2 is None:
109
+ warnings.warn(
110
+ f'Potential capability unavailable ({cap.potential_unavailable_reason}); '
111
+ f'falling back to current-only chart.',
112
+ stacklevel=2,
113
+ )
114
+ paired = False
115
+
116
+ if theme is None:
117
+ theme = get_theme('processbehavior')
118
+ elif isinstance(theme, str):
119
+ theme = get_theme(theme)
120
+
121
+ # Track whether caller explicitly set histnorm
122
+ caller_histnorm = histnorm
123
+
124
+ vals = _normalize_values(values)
125
+ specs = cap.specs
126
+
127
+ # --- Paired mode: two-panel facet ---
128
+ if paired:
129
+ from plotly.subplots import make_subplots
130
+
131
+ fig = make_subplots(
132
+ rows=1,
133
+ cols=2,
134
+ subplot_titles=['Current Capability', 'Potential Capability'],
135
+ )
136
+
137
+ for col, v in enumerate(('current', 'potential'), start=1):
138
+ # Default to "percent" for potential panel when caller didn't specify
139
+ panel_histnorm = caller_histnorm
140
+ if v == 'potential' and not caller_histnorm:
141
+ panel_histnorm = 'percent'
142
+ _render_single_capability(
143
+ fig,
144
+ cap,
145
+ vals,
146
+ specs,
147
+ theme,
148
+ view=v,
149
+ show_potential_text=show_potential,
150
+ nbins=nbins,
151
+ histnorm=panel_histnorm,
152
+ x_label=x_label,
153
+ row=1,
154
+ col=col,
155
+ )
156
+
157
+ fig.update_layout(**theme.to_layout_dict())
158
+ fig.update_layout(
159
+ title=title if title is not None else _auto_title(specs),
160
+ width=width,
161
+ height=height,
162
+ showlegend=True,
163
+ legend=dict(
164
+ orientation='h',
165
+ yanchor='top',
166
+ y=-0.18,
167
+ xanchor='center',
168
+ x=0.5,
169
+ ),
170
+ bargap=0.05,
171
+ )
172
+
173
+ x_lab = x_label or _default_x_label(cap)
174
+ fig.update_xaxes(title_text=x_lab, row=1, col=1)
175
+ fig.update_xaxes(title_text=x_lab, row=1, col=2)
176
+ y_label_left = 'Count' if not caller_histnorm else caller_histnorm.title()
177
+ y_label_right = 'Percent' if not caller_histnorm else caller_histnorm.title()
178
+ fig.update_yaxes(title_text=y_label_left, row=1, col=1)
179
+ fig.update_yaxes(title_text=y_label_right, row=1, col=2)
180
+
181
+ _apply_window_decorations(fig, cap, view='current', paired=True)
182
+ return fig
183
+
184
+ # --- Single chart mode ---
185
+ # Default to "percent" for potential view when caller didn't specify
186
+ if view == 'potential' and not caller_histnorm:
187
+ histnorm = 'percent'
188
+
189
+ fig = go.Figure()
190
+
191
+ _render_single_capability(
192
+ fig,
193
+ cap,
194
+ vals,
195
+ specs,
196
+ theme,
197
+ view=view,
198
+ show_potential_text=show_potential,
199
+ nbins=nbins,
200
+ histnorm=histnorm,
201
+ x_label=x_label,
202
+ )
203
+
204
+ fig.update_layout(**theme.to_layout_dict())
205
+ fig.update_layout(
206
+ title=title if title is not None else _auto_title(specs, view=view),
207
+ xaxis_title=x_label or _default_x_label(cap),
208
+ yaxis_title='Count' if not histnorm else histnorm.title(),
209
+ width=width,
210
+ height=height,
211
+ showlegend=True,
212
+ legend=dict(
213
+ orientation='h',
214
+ yanchor='top',
215
+ y=-0.18,
216
+ xanchor='center',
217
+ x=0.5,
218
+ ),
219
+ bargap=0.05,
220
+ )
221
+
222
+ _apply_window_decorations(fig, cap, view=view, paired=False)
223
+ return fig
224
+
225
+
226
+ # ---------------------------------------------------------------------------
227
+ # Time-window decorations (only applied when cap.window is set)
228
+ # ---------------------------------------------------------------------------
229
+
230
+
231
+ def _window_suffix(cap: CapabilityResult) -> str:
232
+ """Title suffix describing the time window, e.g. ' · week ∈ [25, ∞) · n=216 of 384'."""
233
+ start, end = cap.window
234
+ lo = '-∞' if start is None else str(start)
235
+ hi = '∞' if end is None else str(end)
236
+ return f' · {cap.time_var} ∈ [{lo}, {hi}) · n={cap.n} of {cap.n_total}'
237
+
238
+
239
+ def _apply_window_decorations(fig: go.Figure, cap: CapabilityResult, *, view: str, paired: bool) -> None:
240
+ """Label a windowed capability chart so it is self-describing.
241
+
242
+ No-op when ``cap.window is None`` (the un-windowed chart is unchanged).
243
+ Adds: a window suffix on the title; a prominent thin-window warning banner
244
+ when ``cap.window_warning`` is set; and, on the potential side, a note that
245
+ the spread is the pooled full-study residual floor (not the window's).
246
+ """
247
+ if cap.window is None:
248
+ return
249
+
250
+ current_title = (fig.layout.title.text or '') if fig.layout.title else ''
251
+ fig.update_layout(title=current_title + _window_suffix(cap))
252
+
253
+ if cap.window_warning:
254
+ fig.add_annotation(
255
+ xref='paper', yref='paper', x=0.5, y=1.07, showarrow=False,
256
+ text='⚠ ' + cap.window_warning,
257
+ font=dict(color='#B00020', size=12), align='center',
258
+ )
259
+
260
+ # Make clear the potential panel's spread is pooled (full-study), not the window's.
261
+ if cap.n_total is not None and (paired or view == 'potential'):
262
+ fig.add_annotation(
263
+ xref='paper', yref='paper', x=(0.78 if paired else 0.5), y=0.98,
264
+ showarrow=False,
265
+ text=(f'σ̂ = full-study residual floor (R2, N={cap.n_total}) · '
266
+ f'centered on this window'),
267
+ font=dict(size=10, color='#666666'), align='center',
268
+ )
269
+
270
+
271
+ # ---------------------------------------------------------------------------
272
+ # Single-panel renderer
273
+ # ---------------------------------------------------------------------------
274
+
275
+
276
+ def _render_single_capability(
277
+ fig: go.Figure,
278
+ cap: CapabilityResult,
279
+ vals: np.ndarray,
280
+ specs: SpecLimits,
281
+ theme: ChartTheme,
282
+ *,
283
+ view: str = 'current',
284
+ show_potential_text: bool = True,
285
+ nbins: int | None = None,
286
+ histnorm: str = '',
287
+ x_label: str | None = None,
288
+ row: int | None = None,
289
+ col: int | None = None,
290
+ ) -> None:
291
+ """Render histogram + spec lines + NPL + legend + annotation into fig.
292
+
293
+ When *row*/*col* are ``None``, renders into a plain ``go.Figure``.
294
+ When they are integers, renders into a subplot panel.
295
+ """
296
+ is_subplot = row is not None and col is not None
297
+
298
+ # 1. Histogram — potential view uses recentered R2 values
299
+ hist_data = cap.potential_values if view == 'potential' and cap.potential_values is not None else vals
300
+ hist_kwargs: dict = dict(
301
+ x=hist_data,
302
+ marker_color=theme.data_color,
303
+ opacity=0.75,
304
+ histnorm=histnorm if histnorm else None,
305
+ hovertemplate='Range: %{x}<br>Count: %{y}<extra></extra>',
306
+ showlegend=not is_subplot or col == 1,
307
+ )
308
+ if nbins is not None:
309
+ hist_kwargs['nbinsx'] = nbins
310
+ if is_subplot:
311
+ fig.add_trace(go.Histogram(**hist_kwargs), row=row, col=col)
312
+ else:
313
+ fig.add_trace(go.Histogram(**hist_kwargs))
314
+
315
+ # 2. Out-of-spec shading
316
+ _add_out_of_spec_shading(fig, vals, specs, row=row, col=col)
317
+
318
+ # 3. Spec limit lines
319
+ _add_spec_lines(fig, specs, row=row, col=col)
320
+
321
+ # 4. NPL lines — skip for potential view (only spec lines + mean)
322
+ if view != 'potential':
323
+ _add_npl_lines(fig, cap, theme, view=view, row=row, col=col)
324
+ else:
325
+ # Still draw the mean line for potential view
326
+ _add_mean_line(fig, cap, theme, row=row, col=col)
327
+
328
+ # 5. Legend traces
329
+ _add_legend_traces(fig, cap, theme, view=view, row=row, col=col)
330
+
331
+ # 6. Annotation box
332
+ index_text = _build_index_text(cap, show_potential_text, view=view)
333
+ if is_subplot:
334
+ # Position annotation relative to subplot axis
335
+ xaxis_id = f'x{col}' if col > 1 else 'x'
336
+ yaxis_id = f'y{col}' if col > 1 else 'y'
337
+ fig.add_annotation(
338
+ text=index_text,
339
+ xref=f'{xaxis_id} domain',
340
+ yref=f'{yaxis_id} domain',
341
+ x=0.98,
342
+ y=0.95,
343
+ xanchor='right',
344
+ yanchor='top',
345
+ showarrow=False,
346
+ font=dict(
347
+ family='monospace',
348
+ size=theme.stats_box_font_size,
349
+ color=theme.stats_box_font_color,
350
+ ),
351
+ bgcolor=theme.stats_box_bgcolor,
352
+ bordercolor=theme.stats_box_bordercolor,
353
+ borderwidth=theme.stats_box_borderwidth,
354
+ align='left',
355
+ )
356
+ else:
357
+ fig.add_annotation(
358
+ text=index_text,
359
+ xref='paper',
360
+ yref='paper',
361
+ x=0.98,
362
+ y=0.95,
363
+ xanchor='right',
364
+ yanchor='top',
365
+ showarrow=False,
366
+ font=dict(
367
+ family='monospace',
368
+ size=theme.stats_box_font_size,
369
+ color=theme.stats_box_font_color,
370
+ ),
371
+ bgcolor=theme.stats_box_bgcolor,
372
+ bordercolor=theme.stats_box_bordercolor,
373
+ borderwidth=theme.stats_box_borderwidth,
374
+ align='left',
375
+ )
376
+
377
+
378
+ # ---------------------------------------------------------------------------
379
+ # Helpers
380
+ # ---------------------------------------------------------------------------
381
+
382
+
383
+ def _default_x_label(cap: CapabilityResult) -> str:
384
+ """Return response variable name (title-cased) or 'Value' as fallback."""
385
+ if cap.response_var:
386
+ return cap.response_var.replace('_', ' ').title()
387
+ return 'Value'
388
+
389
+
390
+ def _normalize_values(
391
+ values: Sequence[float] | np.ndarray | pd.Series,
392
+ ) -> np.ndarray:
393
+ """Convert array-like to clean float ndarray (drop NaN)."""
394
+ import pandas as pd
395
+
396
+ return pd.Series(values).astype(float).dropna().to_numpy()
397
+
398
+
399
+ def _build_index_text(
400
+ cap: CapabilityResult,
401
+ show_potential: bool,
402
+ view: str = 'current',
403
+ ) -> str:
404
+ """Build capability index annotation text with deterministic ordering."""
405
+ r = cap.round_to
406
+ specs = cap.specs
407
+
408
+ if view == 'potential':
409
+ # Potential view: show σ̂(R2) and CP/CPL/CPU indices
410
+ lines: list[str] = []
411
+ lines.append(f'n = {cap.n}')
412
+ lines.append(f'\u0232 = {round(cap.y_bar, r)}')
413
+ lines.append(f'\u03c3\u0302(R2) = {round(cap.sigma_hat_r2, r)}')
414
+
415
+ if specs.is_two_sided:
416
+ lines.append('')
417
+ lines.append(f'CP Index = {_fmt(cap.cp, r)}')
418
+ lines.append(f'CPL Index = {_fmt(cap.cpk_lower, r)}')
419
+ lines.append(f'CPU Index = {_fmt(cap.cpk_upper, r)}')
420
+ lines.append('')
421
+ lines.append(f'Pct Below LSL = {_fmt(cap.potential_pct_below_lsl, 2)}%')
422
+ lines.append(f'Pct Above USL = {_fmt(cap.potential_pct_above_usl, 2)}%')
423
+ elif specs.usl is not None:
424
+ lines.append('')
425
+ lines.append(f'CPU Index = {_fmt(cap.cpk_upper, r)}')
426
+ lines.append('')
427
+ lines.append(f'Pct Above USL = {_fmt(cap.potential_pct_above_usl, 2)}%')
428
+ else:
429
+ lines.append('')
430
+ lines.append(f'CPL Index = {_fmt(cap.cpk_lower, r)}')
431
+ lines.append('')
432
+ lines.append(f'Pct Below LSL = {_fmt(cap.potential_pct_below_lsl, 2)}%')
433
+
434
+ return '<br>'.join(lines)
435
+
436
+ # Current view
437
+ lines = []
438
+ lines.append(f'n = {cap.n}')
439
+ lines.append(f'\u0232 = {round(cap.y_bar, r)}')
440
+ lines.append(f'\u03c3\u0302 = {round(cap.sigma_hat, r)}')
441
+
442
+ if specs.is_two_sided:
443
+ lines.append('')
444
+ lines.append(f'PP Index = {_fmt(cap.pp, r)}')
445
+ lines.append(f'PPL Index = {_fmt(cap.ppk_lower, r)}')
446
+ lines.append(f'PPU Index = {_fmt(cap.ppk_upper, r)}')
447
+ lines.append('')
448
+ lines.append(f'Pct Below LSL = {_fmt(cap.pct_below_lsl, 2)}%')
449
+ lines.append(f'Pct Above USL = {_fmt(cap.pct_above_usl, 2)}%')
450
+ elif specs.usl is not None:
451
+ lines.append('')
452
+ lines.append(f'PPU Index = {_fmt(cap.ppk_upper, r)}')
453
+ lines.append('')
454
+ lines.append(f'Pct Above USL = {_fmt(cap.pct_above_usl, 2)}%')
455
+ else:
456
+ lines.append('')
457
+ lines.append(f'PPL Index = {_fmt(cap.ppk_lower, r)}')
458
+ lines.append('')
459
+ lines.append(f'Pct Below LSL = {_fmt(cap.pct_below_lsl, 2)}%')
460
+
461
+ return '<br>'.join(lines)
462
+
463
+
464
+ def _fmt(value: float | None, decimals: int) -> str:
465
+ """Format a float for display, handling None and inf."""
466
+ if value is None:
467
+ return 'N/A'
468
+ if not np.isfinite(value):
469
+ return 'inf'
470
+ return f'{value:.{decimals}f}'
471
+
472
+
473
+ def _get_axis_ref(row: int | None, col: int | None) -> tuple[str, str]:
474
+ """Return (xref, yref) for shapes targeting a specific subplot axis."""
475
+ if row is None or col is None:
476
+ return 'x', 'y'
477
+ # Plotly subplot axes: x, x2, x3... and y, y2, y3...
478
+ idx = (row - 1) * 2 + col # for 1-row layouts: col=1→1, col=2→2
479
+ xref = 'x' if idx == 1 else f'x{idx}'
480
+ yref = 'y' if idx == 1 else f'y{idx}'
481
+ return xref, yref
482
+
483
+
484
+ def _add_spec_lines(
485
+ fig: go.Figure,
486
+ specs: SpecLimits,
487
+ *,
488
+ row: int | None = None,
489
+ col: int | None = None,
490
+ ) -> None:
491
+ """Add USL/LSL/Target vertical lines."""
492
+ if row is not None and col is not None:
493
+ xref, yref = _get_axis_ref(row, col)
494
+ if specs.lsl is not None:
495
+ fig.add_shape(
496
+ type='line',
497
+ x0=specs.lsl,
498
+ x1=specs.lsl,
499
+ y0=0,
500
+ y1=1,
501
+ xref=xref,
502
+ yref=f'{yref} domain',
503
+ line=dict(color=_SPEC_LINE_COLOR, width=2, dash='solid'),
504
+ layer='above',
505
+ )
506
+ if specs.usl is not None:
507
+ fig.add_shape(
508
+ type='line',
509
+ x0=specs.usl,
510
+ x1=specs.usl,
511
+ y0=0,
512
+ y1=1,
513
+ xref=xref,
514
+ yref=f'{yref} domain',
515
+ line=dict(color=_SPEC_LINE_COLOR, width=2, dash='solid'),
516
+ layer='above',
517
+ )
518
+ if specs.target is not None:
519
+ fig.add_shape(
520
+ type='line',
521
+ x0=specs.target,
522
+ x1=specs.target,
523
+ y0=0,
524
+ y1=1,
525
+ xref=xref,
526
+ yref=f'{yref} domain',
527
+ line=dict(color='#2E8B57', width=1.5, dash='dashdot'),
528
+ layer='above',
529
+ )
530
+ else:
531
+ if specs.lsl is not None:
532
+ fig.add_vline(
533
+ x=specs.lsl,
534
+ line_color=_SPEC_LINE_COLOR,
535
+ line_width=2,
536
+ line_dash='solid',
537
+ layer='above',
538
+ )
539
+ if specs.usl is not None:
540
+ fig.add_vline(
541
+ x=specs.usl,
542
+ line_color=_SPEC_LINE_COLOR,
543
+ line_width=2,
544
+ line_dash='solid',
545
+ layer='above',
546
+ )
547
+ if specs.target is not None:
548
+ fig.add_vline(
549
+ x=specs.target,
550
+ line_color='#2E8B57',
551
+ line_width=1.5,
552
+ line_dash='dashdot',
553
+ layer='above',
554
+ )
555
+
556
+
557
+ def _add_npl_lines(
558
+ fig: go.Figure,
559
+ cap: CapabilityResult,
560
+ theme: ChartTheme,
561
+ *,
562
+ view: str = 'current',
563
+ row: int | None = None,
564
+ col: int | None = None,
565
+ ) -> None:
566
+ """Add Natural Process Limit lines: mean, LNPL, UNPL."""
567
+ center_color = theme.center_color
568
+
569
+ # Select sigma based on view
570
+ sigma = cap.sigma_hat_r2 if view == 'potential' else cap.sigma_hat
571
+
572
+ is_subplot = row is not None and col is not None
573
+
574
+ if is_subplot:
575
+ xref, yref = _get_axis_ref(row, col)
576
+
577
+ # Mean line — always drawn
578
+ fig.add_shape(
579
+ type='line',
580
+ x0=cap.y_bar,
581
+ x1=cap.y_bar,
582
+ y0=0,
583
+ y1=1,
584
+ xref=xref,
585
+ yref=f'{yref} domain',
586
+ line=dict(color=center_color, width=2, dash='solid'),
587
+ layer='above',
588
+ )
589
+
590
+ if sigma is None or sigma <= 0:
591
+ if sigma is None:
592
+ logger.warning('sigma is None for view=%s; NPL lines omitted', view)
593
+ else:
594
+ logger.warning('sigma <= 0 (sigma=%s); NPL lines omitted', sigma)
595
+ return
596
+
597
+ lnpl = cap.y_bar - 3 * sigma
598
+ unpl = cap.y_bar + 3 * sigma
599
+
600
+ fig.add_shape(
601
+ type='line',
602
+ x0=lnpl,
603
+ x1=lnpl,
604
+ y0=0,
605
+ y1=1,
606
+ xref=xref,
607
+ yref=f'{yref} domain',
608
+ line=dict(color=center_color, width=1.5, dash='dash'),
609
+ layer='above',
610
+ )
611
+ fig.add_shape(
612
+ type='line',
613
+ x0=unpl,
614
+ x1=unpl,
615
+ y0=0,
616
+ y1=1,
617
+ xref=xref,
618
+ yref=f'{yref} domain',
619
+ line=dict(color=center_color, width=1.5, dash='dash'),
620
+ layer='above',
621
+ )
622
+ else:
623
+ # Mean line — always drawn
624
+ fig.add_vline(
625
+ x=cap.y_bar,
626
+ line_color=center_color,
627
+ line_width=2,
628
+ line_dash='solid',
629
+ layer='above',
630
+ )
631
+
632
+ if sigma is None or sigma <= 0:
633
+ if sigma is None:
634
+ logger.warning('sigma is None for view=%s; NPL lines omitted', view)
635
+ else:
636
+ logger.warning('sigma <= 0 (sigma=%s); NPL lines omitted', sigma)
637
+ return
638
+
639
+ lnpl = cap.y_bar - 3 * sigma
640
+ unpl = cap.y_bar + 3 * sigma
641
+
642
+ fig.add_vline(
643
+ x=lnpl,
644
+ line_color=center_color,
645
+ line_width=1.5,
646
+ line_dash='dash',
647
+ layer='above',
648
+ )
649
+ fig.add_vline(
650
+ x=unpl,
651
+ line_color=center_color,
652
+ line_width=1.5,
653
+ line_dash='dash',
654
+ layer='above',
655
+ )
656
+
657
+
658
+ def _add_mean_line(
659
+ fig: go.Figure,
660
+ cap: CapabilityResult,
661
+ theme: ChartTheme,
662
+ *,
663
+ row: int | None = None,
664
+ col: int | None = None,
665
+ ) -> None:
666
+ """Add only the mean (Y-bar) vertical line — used by potential view."""
667
+ center_color = theme.center_color
668
+ is_subplot = row is not None and col is not None
669
+
670
+ if is_subplot:
671
+ xref, yref = _get_axis_ref(row, col)
672
+ fig.add_shape(
673
+ type='line',
674
+ x0=cap.y_bar,
675
+ x1=cap.y_bar,
676
+ y0=0,
677
+ y1=1,
678
+ xref=xref,
679
+ yref=f'{yref} domain',
680
+ line=dict(color=center_color, width=2, dash='solid'),
681
+ layer='above',
682
+ )
683
+ else:
684
+ fig.add_vline(
685
+ x=cap.y_bar,
686
+ line_color=center_color,
687
+ line_width=2,
688
+ line_dash='solid',
689
+ layer='above',
690
+ )
691
+
692
+
693
+ def _add_legend_traces(
694
+ fig: go.Figure,
695
+ cap: CapabilityResult,
696
+ theme: ChartTheme,
697
+ *,
698
+ view: str = 'current',
699
+ row: int | None = None,
700
+ col: int | None = None,
701
+ ) -> None:
702
+ """Add invisible scatter traces that serve as legend entries for line styles."""
703
+ specs = cap.specs
704
+ center_color = theme.center_color
705
+ is_subplot = row is not None and col is not None
706
+
707
+ # In paired mode, only add legend traces for the first panel
708
+ if is_subplot and col > 1:
709
+ return
710
+
711
+ # Select sigma based on view
712
+ sigma = cap.sigma_hat_r2 if view == 'potential' else cap.sigma_hat
713
+
714
+ # Spec limits (solid crimson)
715
+ has_specs = specs.lsl is not None or specs.usl is not None
716
+ if has_specs:
717
+ spec_parts: list[str] = []
718
+ if specs.lsl is not None:
719
+ spec_parts.append(f'LSL={specs.lsl}')
720
+ if specs.usl is not None:
721
+ spec_parts.append(f'USL={specs.usl}')
722
+ trace_kwargs = dict(
723
+ x=[None],
724
+ y=[None],
725
+ mode='lines',
726
+ line=dict(color=_SPEC_LINE_COLOR, width=2, dash='solid'),
727
+ name=f'Spec Limits ({", ".join(spec_parts)})',
728
+ )
729
+ if is_subplot:
730
+ fig.add_trace(go.Scatter(**trace_kwargs), row=row, col=col)
731
+ else:
732
+ fig.add_trace(go.Scatter(**trace_kwargs))
733
+
734
+ # Target (green dashdot)
735
+ if specs.target is not None:
736
+ trace_kwargs = dict(
737
+ x=[None],
738
+ y=[None],
739
+ mode='lines',
740
+ line=dict(color='#2E8B57', width=1.5, dash='dashdot'),
741
+ name=f'Target ({specs.target})',
742
+ )
743
+ if is_subplot:
744
+ fig.add_trace(go.Scatter(**trace_kwargs), row=row, col=col)
745
+ else:
746
+ fig.add_trace(go.Scatter(**trace_kwargs))
747
+
748
+ # Y-bar (solid green)
749
+ trace_kwargs = dict(
750
+ x=[None],
751
+ y=[None],
752
+ mode='lines',
753
+ line=dict(color=center_color, width=2, dash='solid'),
754
+ name=f'Y-bar ({round(cap.y_bar, cap.round_to)})',
755
+ )
756
+ if is_subplot:
757
+ fig.add_trace(go.Scatter(**trace_kwargs), row=row, col=col)
758
+ else:
759
+ fig.add_trace(go.Scatter(**trace_kwargs))
760
+
761
+ # NPL (dashed green) — uses view-dependent sigma; skip for potential view
762
+ if view != 'potential' and sigma is not None and sigma > 0:
763
+ lnpl = round(cap.y_bar - 3 * sigma, cap.round_to)
764
+ unpl = round(cap.y_bar + 3 * sigma, cap.round_to)
765
+ trace_kwargs = dict(
766
+ x=[None],
767
+ y=[None],
768
+ mode='lines',
769
+ line=dict(color=center_color, width=1.5, dash='dash'),
770
+ name=f'NPL ({lnpl}, {unpl})',
771
+ )
772
+ if is_subplot:
773
+ fig.add_trace(go.Scatter(**trace_kwargs), row=row, col=col)
774
+ else:
775
+ fig.add_trace(go.Scatter(**trace_kwargs))
776
+
777
+
778
+ def _add_out_of_spec_shading(
779
+ fig: go.Figure,
780
+ vals: np.ndarray,
781
+ specs: SpecLimits,
782
+ *,
783
+ row: int | None = None,
784
+ col: int | None = None,
785
+ ) -> None:
786
+ """Add padded vrects beyond spec limits."""
787
+ if len(vals) == 0:
788
+ return
789
+
790
+ xmin, xmax = float(vals.min()), float(vals.max())
791
+ pad = 0.05 * (xmax - xmin) if xmax > xmin else 1.0
792
+ xminp = xmin - pad
793
+ xmaxp = xmax + pad
794
+
795
+ is_subplot = row is not None and col is not None
796
+
797
+ if is_subplot:
798
+ xref, yref = _get_axis_ref(row, col)
799
+ if specs.lsl is not None and specs.lsl > xminp:
800
+ fig.add_shape(
801
+ type='rect',
802
+ x0=xminp,
803
+ x1=specs.lsl,
804
+ y0=0,
805
+ y1=1,
806
+ xref=xref,
807
+ yref=f'{yref} domain',
808
+ fillcolor=_SPEC_LINE_COLOR,
809
+ opacity=_SPEC_SHADE_OPACITY,
810
+ line_width=0,
811
+ layer='below',
812
+ )
813
+ if specs.usl is not None and specs.usl < xmaxp:
814
+ fig.add_shape(
815
+ type='rect',
816
+ x0=specs.usl,
817
+ x1=xmaxp,
818
+ y0=0,
819
+ y1=1,
820
+ xref=xref,
821
+ yref=f'{yref} domain',
822
+ fillcolor=_SPEC_LINE_COLOR,
823
+ opacity=_SPEC_SHADE_OPACITY,
824
+ line_width=0,
825
+ layer='below',
826
+ )
827
+ else:
828
+ if specs.lsl is not None and specs.lsl > xminp:
829
+ fig.add_vrect(
830
+ x0=xminp,
831
+ x1=specs.lsl,
832
+ fillcolor=_SPEC_LINE_COLOR,
833
+ opacity=_SPEC_SHADE_OPACITY,
834
+ line_width=0,
835
+ layer='below',
836
+ )
837
+ if specs.usl is not None and specs.usl < xmaxp:
838
+ fig.add_vrect(
839
+ x0=specs.usl,
840
+ x1=xmaxp,
841
+ fillcolor=_SPEC_LINE_COLOR,
842
+ opacity=_SPEC_SHADE_OPACITY,
843
+ line_width=0,
844
+ layer='below',
845
+ )
846
+
847
+
848
+ def _auto_title(specs: SpecLimits, *, view: str = 'current') -> str:
849
+ """Generate title from SpecLimits."""
850
+ parts: list[str] = []
851
+ if specs.lsl is not None:
852
+ parts.append(f'LSL={specs.lsl}')
853
+ if specs.target is not None:
854
+ parts.append(f'Target={specs.target}')
855
+ if specs.usl is not None:
856
+ parts.append(f'USL={specs.usl}')
857
+ prefix = 'Potential' if view == 'potential' else 'Process'
858
+ return f'{prefix} Capability ({", ".join(parts)})'