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,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)})'
|