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,660 @@
|
|
|
1
|
+
"""Shared rendering pipeline for control charts and histograms.
|
|
2
|
+
|
|
3
|
+
Provides one code path used by both single and faceted chart layouts.
|
|
4
|
+
All rendering goes through ``render_control_chart()`` or
|
|
5
|
+
``render_histogram()``, eliminating the duplication that previously
|
|
6
|
+
existed between ``_plot_single_chart`` and ``_plot_faceted``.
|
|
7
|
+
|
|
8
|
+
The key insight: Plotly's ``add_hline()`` doesn't support subplot
|
|
9
|
+
targeting (``row``/``col``), so we use ``add_shape(type='line')`` +
|
|
10
|
+
``add_annotation()`` everywhere. This lets the same function handle
|
|
11
|
+
both single and faceted charts.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from typing import TYPE_CHECKING
|
|
17
|
+
|
|
18
|
+
import numpy as np
|
|
19
|
+
import plotly.graph_objects as go
|
|
20
|
+
|
|
21
|
+
from ..types import ChartPayload
|
|
22
|
+
from .contracts import RenderContext
|
|
23
|
+
from .lane_boundaries import add_lane_boundaries
|
|
24
|
+
from .limits import add_stepped_limit_line, format_limit_label
|
|
25
|
+
from .run_rules_viz import add_run_rules_visualization
|
|
26
|
+
from .stats_box import add_stats_box
|
|
27
|
+
from .zones import add_zone_shading
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
import pandas as pd
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# ---------------------------------------------------------------------------
|
|
34
|
+
# Hover construction
|
|
35
|
+
# ---------------------------------------------------------------------------
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _build_hover(
|
|
39
|
+
data: pd.DataFrame,
|
|
40
|
+
ctx: RenderContext,
|
|
41
|
+
value_col: str,
|
|
42
|
+
) -> tuple[list | None, str]:
|
|
43
|
+
"""Build (customdata, hovertemplate) for the main data trace.
|
|
44
|
+
|
|
45
|
+
When the x-axis falls back to integer position because the time
|
|
46
|
+
variable is non-unique (the dense multi-cell X-chart case), the
|
|
47
|
+
default hover ``%{x}<br>%{y:.3f}`` shows just the row index — useless
|
|
48
|
+
once production_time is no longer a tick label. So we attach
|
|
49
|
+
customdata carrying [cell, time, obs_id] when those columns exist
|
|
50
|
+
and surface them in the template.
|
|
51
|
+
|
|
52
|
+
Returns (None, default_template) for charts where the x-axis already
|
|
53
|
+
carries enough context (categorical x_col), preserving existing
|
|
54
|
+
behavior for Xbar/S charts and other paths.
|
|
55
|
+
"""
|
|
56
|
+
spec = ctx.spec
|
|
57
|
+
x_col = spec.x_col
|
|
58
|
+
cell_col = 'rsg' if 'rsg' in data.columns else None
|
|
59
|
+
time_var: str | None = None
|
|
60
|
+
for cand in ('PRODUCTION TIME', 'time', 'production_time', 'PRODUCTION_TIME'):
|
|
61
|
+
if cand in data.columns and cand != x_col and cand != value_col:
|
|
62
|
+
time_var = cand
|
|
63
|
+
break
|
|
64
|
+
has_obs_id = 'obs_id' in data.columns
|
|
65
|
+
|
|
66
|
+
# Only enrich when x falls back to integer position AND we have
|
|
67
|
+
# something useful to add. Categorical-x charts (Xbar by factor) and
|
|
68
|
+
# single-cell charts get the existing default template.
|
|
69
|
+
if x_col is not None or (cell_col is None and time_var is None and not has_obs_id):
|
|
70
|
+
return None, '%{x}<br>%{y:.3f}<extra></extra>'
|
|
71
|
+
|
|
72
|
+
parts = [f'<b>{ctx.chart_name}</b> = %{{y:.3f}}']
|
|
73
|
+
custom_cols: list[str] = []
|
|
74
|
+
template_pieces: list[str] = []
|
|
75
|
+
if cell_col:
|
|
76
|
+
custom_cols.append(cell_col)
|
|
77
|
+
template_pieces.append(f'Cell: %{{customdata[{len(custom_cols) - 1}]}}')
|
|
78
|
+
if time_var and time_var in data.columns:
|
|
79
|
+
custom_cols.append(time_var)
|
|
80
|
+
template_pieces.append(f'Time: %{{customdata[{len(custom_cols) - 1}]}}')
|
|
81
|
+
if has_obs_id:
|
|
82
|
+
custom_cols.append('obs_id')
|
|
83
|
+
template_pieces.append(f'Obs: %{{customdata[{len(custom_cols) - 1}]}}')
|
|
84
|
+
|
|
85
|
+
if not custom_cols:
|
|
86
|
+
return None, '%{x}<br>%{y:.3f}<extra></extra>'
|
|
87
|
+
|
|
88
|
+
customdata = data[custom_cols].astype(object).values.tolist()
|
|
89
|
+
hovertemplate = '<br>'.join(parts + template_pieces) + '<extra></extra>'
|
|
90
|
+
return customdata, hovertemplate
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# ---------------------------------------------------------------------------
|
|
94
|
+
# Control chart rendering pipeline
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def render_control_chart(
|
|
99
|
+
fig: go.Figure,
|
|
100
|
+
ctx: RenderContext,
|
|
101
|
+
row: int | None = None,
|
|
102
|
+
col: int | None = None,
|
|
103
|
+
nrows: int | None = None,
|
|
104
|
+
ncols: int | None = None,
|
|
105
|
+
) -> None:
|
|
106
|
+
"""Render one control chart panel onto *fig*.
|
|
107
|
+
|
|
108
|
+
This is the single rendering pipeline used by both single-chart and
|
|
109
|
+
faceted layouts. The ``row``/``col`` parameters determine subplot
|
|
110
|
+
targeting; when both are ``None`` the chart is drawn on a plain
|
|
111
|
+
``go.Figure()``.
|
|
112
|
+
|
|
113
|
+
Parameters
|
|
114
|
+
----------
|
|
115
|
+
fig : go.Figure
|
|
116
|
+
Target figure (may be a plain figure or a ``make_subplots`` grid).
|
|
117
|
+
ctx : RenderContext
|
|
118
|
+
Immutable rendering context carrying data, stats, theme and options.
|
|
119
|
+
row, col : int | None
|
|
120
|
+
1-indexed subplot coordinates, or ``None`` for single-chart mode.
|
|
121
|
+
nrows, ncols : int | None
|
|
122
|
+
Grid dimensions (needed by ``add_stats_box`` for position math).
|
|
123
|
+
"""
|
|
124
|
+
data = ctx.data
|
|
125
|
+
stats = ctx.stats
|
|
126
|
+
theme = ctx.theme
|
|
127
|
+
spec = ctx.spec
|
|
128
|
+
value_col = spec.value_col
|
|
129
|
+
x_col = spec.x_col
|
|
130
|
+
|
|
131
|
+
x_data = data[x_col] if x_col and x_col in data.columns else data.index
|
|
132
|
+
|
|
133
|
+
# 1. Zone shading (behind everything)
|
|
134
|
+
if ctx.show_zones and theme.zone_opacity > 0:
|
|
135
|
+
add_zone_shading(fig, stats, theme, row=row, col=col, ncols=ncols)
|
|
136
|
+
|
|
137
|
+
# 2. Main data trace
|
|
138
|
+
customdata, hovertemplate = _build_hover(data, ctx, value_col)
|
|
139
|
+
trace_kw = dict(
|
|
140
|
+
x=x_data,
|
|
141
|
+
y=data[value_col],
|
|
142
|
+
mode='lines+markers',
|
|
143
|
+
name=ctx.chart_name,
|
|
144
|
+
marker=dict(size=ctx.marker_size, color=theme.data_color),
|
|
145
|
+
line=dict(color=theme.data_color, width=ctx.line_width),
|
|
146
|
+
opacity=theme.data_opacity,
|
|
147
|
+
hovertemplate=hovertemplate,
|
|
148
|
+
)
|
|
149
|
+
if customdata is not None:
|
|
150
|
+
trace_kw['customdata'] = customdata
|
|
151
|
+
if ctx.is_faceted:
|
|
152
|
+
trace_kw['showlegend'] = False
|
|
153
|
+
_add_trace(fig, go.Scatter(**trace_kw), row, col)
|
|
154
|
+
|
|
155
|
+
# 3. Control limits and centerline
|
|
156
|
+
if ctx.show_limits:
|
|
157
|
+
_add_limits(fig, ctx, x_data, row, col, ncols)
|
|
158
|
+
|
|
159
|
+
# 4. Signal highlighting (Rule 1 — beyond limits)
|
|
160
|
+
if ctx.highlight_signals and 'beyond_limits' in data.columns:
|
|
161
|
+
_add_signals(fig, ctx, x_data, row, col)
|
|
162
|
+
|
|
163
|
+
# 5. Run rules (Rules 2-8)
|
|
164
|
+
if ctx.show_rules and spec.run_rules_applicable:
|
|
165
|
+
add_run_rules_visualization(
|
|
166
|
+
fig,
|
|
167
|
+
data,
|
|
168
|
+
stats,
|
|
169
|
+
ctx.chart_name,
|
|
170
|
+
value_col,
|
|
171
|
+
x_col,
|
|
172
|
+
theme,
|
|
173
|
+
result=ctx.result,
|
|
174
|
+
row=row,
|
|
175
|
+
col=col,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
# 6. Stats box
|
|
179
|
+
if ctx.show_stats:
|
|
180
|
+
add_stats_box(
|
|
181
|
+
fig,
|
|
182
|
+
stats,
|
|
183
|
+
data,
|
|
184
|
+
theme,
|
|
185
|
+
row=row,
|
|
186
|
+
col=col,
|
|
187
|
+
nrows=nrows,
|
|
188
|
+
ncols=ncols,
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# 7. Lane boundaries
|
|
192
|
+
lane_boundaries = spec.lane_boundaries
|
|
193
|
+
if lane_boundaries:
|
|
194
|
+
# Handle dict-keyed boundaries (per-stratum in faceted)
|
|
195
|
+
if isinstance(lane_boundaries, dict):
|
|
196
|
+
lane_boundaries = lane_boundaries.get(ctx.chart_name)
|
|
197
|
+
if lane_boundaries:
|
|
198
|
+
y_min = data[value_col].min()
|
|
199
|
+
y_max = data[value_col].max()
|
|
200
|
+
y_pad = (y_max - y_min) * 0.05
|
|
201
|
+
y_range = (y_min - y_pad, y_max + y_pad)
|
|
202
|
+
# Single-chart mode: cell labels are rendered as a band below the
|
|
203
|
+
# axis title by Plotter._apply_time_tick_labels, so suppress the
|
|
204
|
+
# top-of-plot lane labels here. Faceted mode keeps top labels
|
|
205
|
+
# per panel.
|
|
206
|
+
add_lane_boundaries(
|
|
207
|
+
fig,
|
|
208
|
+
lane_boundaries,
|
|
209
|
+
y_range,
|
|
210
|
+
theme,
|
|
211
|
+
row=row,
|
|
212
|
+
col=col,
|
|
213
|
+
show_labels=ctx.is_faceted,
|
|
214
|
+
)
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
# ---------------------------------------------------------------------------
|
|
218
|
+
# Histogram rendering pipeline
|
|
219
|
+
# ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def render_histogram(
|
|
223
|
+
fig: go.Figure,
|
|
224
|
+
chart_info: ChartPayload,
|
|
225
|
+
theme,
|
|
226
|
+
*,
|
|
227
|
+
show_stats: bool = True,
|
|
228
|
+
row: int | None = None,
|
|
229
|
+
col: int | None = None,
|
|
230
|
+
shared_bin_edges: np.ndarray | None = None,
|
|
231
|
+
is_faceted: bool = False,
|
|
232
|
+
histnorm: str = '',
|
|
233
|
+
) -> None:
|
|
234
|
+
"""Render one histogram panel onto *fig*.
|
|
235
|
+
|
|
236
|
+
Handles both standalone and faceted histograms with optional
|
|
237
|
+
shared bin edges and mean/std overlay lines.
|
|
238
|
+
|
|
239
|
+
Parameters
|
|
240
|
+
----------
|
|
241
|
+
fig : go.Figure
|
|
242
|
+
Target figure.
|
|
243
|
+
chart_info : dict
|
|
244
|
+
Chart info with 'data', 'statistics', and 'metadata'.
|
|
245
|
+
theme : ChartTheme
|
|
246
|
+
Visual theme.
|
|
247
|
+
show_stats : bool
|
|
248
|
+
Whether to overlay mean/std deviation lines.
|
|
249
|
+
row, col : int | None
|
|
250
|
+
Subplot coordinates (None for standalone).
|
|
251
|
+
shared_bin_edges : np.ndarray | None
|
|
252
|
+
Pre-computed bin edges for consistent cross-facet binning.
|
|
253
|
+
is_faceted : bool
|
|
254
|
+
Whether this is part of a faceted layout.
|
|
255
|
+
histnorm : str, default ""
|
|
256
|
+
Histogram normalization mode (``""`` for counts,
|
|
257
|
+
``"percent"`` for percentages). Also checked in
|
|
258
|
+
``chart_info['metadata']['histnorm']`` as fallback.
|
|
259
|
+
"""
|
|
260
|
+
metadata = chart_info.get('metadata', {})
|
|
261
|
+
stats = chart_info.get('statistics', {})
|
|
262
|
+
data = chart_info['data']
|
|
263
|
+
|
|
264
|
+
value_col = metadata.get('value_col')
|
|
265
|
+
bins = metadata.get('bins')
|
|
266
|
+
chart_name = metadata.get('chart_type', 'Histogram')
|
|
267
|
+
|
|
268
|
+
# Resolve histnorm: explicit parameter wins, then metadata fallback
|
|
269
|
+
effective_histnorm = histnorm or metadata.get('histnorm', '')
|
|
270
|
+
|
|
271
|
+
# Histogram trace
|
|
272
|
+
if shared_bin_edges is not None:
|
|
273
|
+
bin_width = shared_bin_edges[1] - shared_bin_edges[0]
|
|
274
|
+
hist_trace = go.Histogram(
|
|
275
|
+
x=data[value_col],
|
|
276
|
+
xbins=dict(
|
|
277
|
+
start=shared_bin_edges[0],
|
|
278
|
+
end=shared_bin_edges[-1],
|
|
279
|
+
size=bin_width,
|
|
280
|
+
),
|
|
281
|
+
name=chart_name,
|
|
282
|
+
marker_color=theme.data_color,
|
|
283
|
+
opacity=0.75,
|
|
284
|
+
showlegend=False,
|
|
285
|
+
histnorm=effective_histnorm if effective_histnorm else None,
|
|
286
|
+
)
|
|
287
|
+
else:
|
|
288
|
+
hist_trace = go.Histogram(
|
|
289
|
+
x=data[value_col],
|
|
290
|
+
nbinsx=bins,
|
|
291
|
+
name=value_col if not is_faceted else chart_name,
|
|
292
|
+
marker_color=theme.data_color,
|
|
293
|
+
opacity=0.75,
|
|
294
|
+
showlegend=is_faceted is False, # standalone shows legend default
|
|
295
|
+
histnorm=effective_histnorm if effective_histnorm else None,
|
|
296
|
+
)
|
|
297
|
+
_add_trace(fig, hist_trace, row, col)
|
|
298
|
+
|
|
299
|
+
# Stats overlay lines
|
|
300
|
+
if show_stats:
|
|
301
|
+
_add_histogram_stats(fig, stats, theme, row, col, is_faceted)
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
# ---------------------------------------------------------------------------
|
|
305
|
+
# Internal helpers
|
|
306
|
+
# ---------------------------------------------------------------------------
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _subplot_kwargs(row: int | None, col: int | None) -> dict:
|
|
310
|
+
"""Build ``**subplot_kwargs`` for Plotly calls that accept row/col."""
|
|
311
|
+
if row is not None and col is not None:
|
|
312
|
+
return {'row': row, 'col': col}
|
|
313
|
+
return {}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
def _add_trace(
|
|
317
|
+
fig: go.Figure,
|
|
318
|
+
trace: go.Scatter | go.Histogram,
|
|
319
|
+
row: int | None,
|
|
320
|
+
col: int | None,
|
|
321
|
+
) -> None:
|
|
322
|
+
"""Add a trace to fig, targeting subplot if row/col given."""
|
|
323
|
+
if row is not None and col is not None:
|
|
324
|
+
fig.add_trace(trace, row=row, col=col)
|
|
325
|
+
else:
|
|
326
|
+
fig.add_trace(trace)
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _add_fixed_limit(
|
|
330
|
+
fig: go.Figure,
|
|
331
|
+
y: float,
|
|
332
|
+
label: str,
|
|
333
|
+
color: str,
|
|
334
|
+
dash: str,
|
|
335
|
+
width: float,
|
|
336
|
+
font_size: int,
|
|
337
|
+
row: int | None,
|
|
338
|
+
col: int | None,
|
|
339
|
+
ncols: int | None = None,
|
|
340
|
+
) -> None:
|
|
341
|
+
"""Draw a fixed horizontal limit line using add_shape + add_annotation.
|
|
342
|
+
|
|
343
|
+
Uses domain-relative x-coordinates (0–1) so the line always spans the
|
|
344
|
+
full subplot width regardless of x-data type. This matches the pattern
|
|
345
|
+
used by ``add_zone_shading``.
|
|
346
|
+
"""
|
|
347
|
+
subplot_kw = _subplot_kwargs(row, col)
|
|
348
|
+
|
|
349
|
+
# Build domain-relative xref (same logic as zones.py)
|
|
350
|
+
if row is not None and col is not None:
|
|
351
|
+
subplot_idx = (row - 1) * (ncols or 1) + col
|
|
352
|
+
xref = 'x domain' if subplot_idx == 1 else f'x{subplot_idx} domain'
|
|
353
|
+
else:
|
|
354
|
+
xref = 'x domain'
|
|
355
|
+
|
|
356
|
+
fig.add_shape(
|
|
357
|
+
type='line',
|
|
358
|
+
x0=0,
|
|
359
|
+
x1=1,
|
|
360
|
+
xref=xref,
|
|
361
|
+
y0=y,
|
|
362
|
+
y1=y,
|
|
363
|
+
line=dict(color=color, dash=dash, width=width),
|
|
364
|
+
**subplot_kw,
|
|
365
|
+
)
|
|
366
|
+
|
|
367
|
+
|
|
368
|
+
def _add_limit_summary_annotation(
|
|
369
|
+
fig: go.Figure,
|
|
370
|
+
ctx: RenderContext,
|
|
371
|
+
row: int | None,
|
|
372
|
+
col: int | None,
|
|
373
|
+
ncols: int | None = None,
|
|
374
|
+
) -> None:
|
|
375
|
+
"""Place a single summary annotation above a subplot with UPL/CL/LPL values.
|
|
376
|
+
|
|
377
|
+
Replaces the per-line right-side annotations with a compact pipe-delimited
|
|
378
|
+
string like ``UPL = 238.6 | CL = 237.4 | LPL = 236.2``.
|
|
379
|
+
"""
|
|
380
|
+
stats = ctx.stats
|
|
381
|
+
spec = ctx.spec
|
|
382
|
+
theme = ctx.theme
|
|
383
|
+
|
|
384
|
+
parts: list[str] = []
|
|
385
|
+
|
|
386
|
+
# UPL
|
|
387
|
+
if stats.get('upl') is not None:
|
|
388
|
+
parts.append(format_limit_label('UPL', stats['upl'], True))
|
|
389
|
+
|
|
390
|
+
# CL
|
|
391
|
+
center_key = spec.center_key
|
|
392
|
+
if center_key and stats.get(center_key) is not None:
|
|
393
|
+
parts.append(format_limit_label('CL', stats[center_key], True))
|
|
394
|
+
|
|
395
|
+
# LPL
|
|
396
|
+
if stats.get('lpl') is not None:
|
|
397
|
+
parts.append(format_limit_label('LPL', stats['lpl'], True))
|
|
398
|
+
|
|
399
|
+
if not parts:
|
|
400
|
+
return
|
|
401
|
+
|
|
402
|
+
text = ' | '.join(parts)
|
|
403
|
+
|
|
404
|
+
# Build domain-relative refs.
|
|
405
|
+
# For single-chart mode, place the annotation in paper coords just
|
|
406
|
+
# below the title so it never collides with in-plot decorations
|
|
407
|
+
# (lane labels, signal markers, run-rule annotations). For faceted
|
|
408
|
+
# mode keep the per-subplot domain placement.
|
|
409
|
+
subplot_kw = _subplot_kwargs(row, col)
|
|
410
|
+
if row is not None and col is not None:
|
|
411
|
+
subplot_idx = (row - 1) * (ncols or 1) + col
|
|
412
|
+
xref = 'x domain' if subplot_idx == 1 else f'x{subplot_idx} domain'
|
|
413
|
+
yref = 'y domain' if subplot_idx == 1 else f'y{subplot_idx} domain'
|
|
414
|
+
x_pos, y_pos, yanchor = 1.0, 1.0, 'top'
|
|
415
|
+
else:
|
|
416
|
+
xref = 'paper'
|
|
417
|
+
yref = 'paper'
|
|
418
|
+
x_pos, y_pos, yanchor = 1.0, 1.04, 'bottom'
|
|
419
|
+
|
|
420
|
+
fig.add_annotation(
|
|
421
|
+
x=x_pos,
|
|
422
|
+
xref=xref,
|
|
423
|
+
y=y_pos,
|
|
424
|
+
yref=yref,
|
|
425
|
+
yanchor=yanchor,
|
|
426
|
+
xanchor='right',
|
|
427
|
+
text=text,
|
|
428
|
+
showarrow=False,
|
|
429
|
+
font=dict(
|
|
430
|
+
size=theme.annotation_font_size,
|
|
431
|
+
color=theme.limit_summary_color,
|
|
432
|
+
),
|
|
433
|
+
bgcolor=theme.limit_summary_bgcolor,
|
|
434
|
+
**subplot_kw,
|
|
435
|
+
)
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _add_limits(
|
|
439
|
+
fig: go.Figure,
|
|
440
|
+
ctx: RenderContext,
|
|
441
|
+
x_data,
|
|
442
|
+
row: int | None,
|
|
443
|
+
col: int | None,
|
|
444
|
+
ncols: int | None = None,
|
|
445
|
+
) -> None:
|
|
446
|
+
"""Add UPL, LPL, centerline (fixed or stepped) and vary annotation."""
|
|
447
|
+
stats = ctx.stats
|
|
448
|
+
theme = ctx.theme
|
|
449
|
+
spec = ctx.spec
|
|
450
|
+
data = ctx.data
|
|
451
|
+
x_col = spec.x_col
|
|
452
|
+
font_size = theme.annotation_font_size
|
|
453
|
+
|
|
454
|
+
# ---- UPL ----
|
|
455
|
+
if 'upl' in stats:
|
|
456
|
+
if stats['upl'] is not None:
|
|
457
|
+
label = format_limit_label('UPL', stats['upl'], ctx.show_limit_values)
|
|
458
|
+
_add_fixed_limit(
|
|
459
|
+
fig,
|
|
460
|
+
stats['upl'],
|
|
461
|
+
label,
|
|
462
|
+
theme.ucl_color,
|
|
463
|
+
theme.limit_line_dash,
|
|
464
|
+
theme.limit_line_width,
|
|
465
|
+
font_size,
|
|
466
|
+
row,
|
|
467
|
+
col,
|
|
468
|
+
ncols,
|
|
469
|
+
)
|
|
470
|
+
elif 'upl' in data.columns:
|
|
471
|
+
add_stepped_limit_line(
|
|
472
|
+
fig,
|
|
473
|
+
data,
|
|
474
|
+
x_col,
|
|
475
|
+
'upl',
|
|
476
|
+
theme.ucl_color,
|
|
477
|
+
theme.limit_line_dash,
|
|
478
|
+
theme.limit_line_width,
|
|
479
|
+
'UPL',
|
|
480
|
+
theme,
|
|
481
|
+
row=row,
|
|
482
|
+
col=col,
|
|
483
|
+
)
|
|
484
|
+
|
|
485
|
+
# ---- LPL ----
|
|
486
|
+
if 'lpl' in stats:
|
|
487
|
+
if stats['lpl'] is not None:
|
|
488
|
+
label = format_limit_label('LPL', stats['lpl'], ctx.show_limit_values)
|
|
489
|
+
_add_fixed_limit(
|
|
490
|
+
fig,
|
|
491
|
+
stats['lpl'],
|
|
492
|
+
label,
|
|
493
|
+
theme.lcl_color,
|
|
494
|
+
theme.limit_line_dash,
|
|
495
|
+
theme.limit_line_width,
|
|
496
|
+
font_size,
|
|
497
|
+
row,
|
|
498
|
+
col,
|
|
499
|
+
ncols,
|
|
500
|
+
)
|
|
501
|
+
elif 'lpl' in data.columns:
|
|
502
|
+
add_stepped_limit_line(
|
|
503
|
+
fig,
|
|
504
|
+
data,
|
|
505
|
+
x_col,
|
|
506
|
+
'lpl',
|
|
507
|
+
theme.lcl_color,
|
|
508
|
+
theme.limit_line_dash,
|
|
509
|
+
theme.limit_line_width,
|
|
510
|
+
'LPL',
|
|
511
|
+
theme,
|
|
512
|
+
row=row,
|
|
513
|
+
col=col,
|
|
514
|
+
)
|
|
515
|
+
|
|
516
|
+
# ---- "Limits vary" annotation (single chart only) ----
|
|
517
|
+
if spec.limits_vary and not ctx.is_faceted:
|
|
518
|
+
vary_text = 'Process limits computed per phase' if spec.phased else 'Process limits vary by subgroup size (n)'
|
|
519
|
+
fig.add_annotation(
|
|
520
|
+
text=vary_text,
|
|
521
|
+
xref='paper',
|
|
522
|
+
yref='paper',
|
|
523
|
+
x=0.02,
|
|
524
|
+
y=0.98,
|
|
525
|
+
showarrow=False,
|
|
526
|
+
font=dict(size=10, color='gray'),
|
|
527
|
+
bgcolor='rgba(255,255,255,0.8)',
|
|
528
|
+
borderpad=3,
|
|
529
|
+
)
|
|
530
|
+
|
|
531
|
+
# ---- Centerline ----
|
|
532
|
+
center_key = spec.center_key
|
|
533
|
+
if center_key and center_key in stats:
|
|
534
|
+
if stats[center_key] is None:
|
|
535
|
+
if 'center' in data.columns:
|
|
536
|
+
add_stepped_limit_line(
|
|
537
|
+
fig,
|
|
538
|
+
data,
|
|
539
|
+
x_col,
|
|
540
|
+
'center',
|
|
541
|
+
theme.center_color,
|
|
542
|
+
'solid',
|
|
543
|
+
theme.center_line_width,
|
|
544
|
+
'CL',
|
|
545
|
+
theme,
|
|
546
|
+
row=row,
|
|
547
|
+
col=col,
|
|
548
|
+
)
|
|
549
|
+
else:
|
|
550
|
+
label = format_limit_label('CL', stats[center_key], ctx.show_limit_values)
|
|
551
|
+
_add_fixed_limit(
|
|
552
|
+
fig,
|
|
553
|
+
stats[center_key],
|
|
554
|
+
label,
|
|
555
|
+
theme.center_color,
|
|
556
|
+
'solid',
|
|
557
|
+
theme.center_line_width,
|
|
558
|
+
font_size,
|
|
559
|
+
row,
|
|
560
|
+
col,
|
|
561
|
+
ncols,
|
|
562
|
+
)
|
|
563
|
+
|
|
564
|
+
# ---- Limit summary annotation ----
|
|
565
|
+
if ctx.show_limit_values:
|
|
566
|
+
_add_limit_summary_annotation(fig, ctx, row, col, ncols)
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
def _add_signals(
|
|
570
|
+
fig: go.Figure,
|
|
571
|
+
ctx: RenderContext,
|
|
572
|
+
x_data,
|
|
573
|
+
row: int | None,
|
|
574
|
+
col: int | None,
|
|
575
|
+
) -> None:
|
|
576
|
+
"""Add signal markers (Rule 1 — beyond limits)."""
|
|
577
|
+
data = ctx.data
|
|
578
|
+
theme = ctx.theme
|
|
579
|
+
value_col = ctx.spec.value_col
|
|
580
|
+
x_col = ctx.spec.x_col
|
|
581
|
+
|
|
582
|
+
signals = data[data['beyond_limits'] != 0]
|
|
583
|
+
if signals.empty:
|
|
584
|
+
return
|
|
585
|
+
|
|
586
|
+
sig_x = signals[x_col] if x_col and x_col in data.columns else signals.index
|
|
587
|
+
|
|
588
|
+
marker = dict(
|
|
589
|
+
size=ctx.marker_size,
|
|
590
|
+
color=theme.signal_color,
|
|
591
|
+
symbol=theme.signal_marker_symbol,
|
|
592
|
+
)
|
|
593
|
+
# Single charts get the marker-line detail
|
|
594
|
+
if not ctx.is_faceted:
|
|
595
|
+
marker['line'] = dict(
|
|
596
|
+
width=theme.signal_marker_line_width,
|
|
597
|
+
color=theme.signal_marker_line_color,
|
|
598
|
+
)
|
|
599
|
+
|
|
600
|
+
hover_label = 'Signal' if ctx.is_faceted else 'Beyond Limits'
|
|
601
|
+
trace = go.Scatter(
|
|
602
|
+
x=sig_x,
|
|
603
|
+
y=signals[value_col],
|
|
604
|
+
mode='markers',
|
|
605
|
+
name=hover_label,
|
|
606
|
+
marker=marker,
|
|
607
|
+
showlegend=False,
|
|
608
|
+
hovertemplate=f'{hover_label}<br>%{{x}}<br>%{{y:.3f}}<extra></extra>',
|
|
609
|
+
)
|
|
610
|
+
_add_trace(fig, trace, row, col)
|
|
611
|
+
|
|
612
|
+
|
|
613
|
+
def _add_histogram_stats(
|
|
614
|
+
fig: go.Figure,
|
|
615
|
+
stats: dict,
|
|
616
|
+
theme,
|
|
617
|
+
row: int | None,
|
|
618
|
+
col: int | None,
|
|
619
|
+
is_faceted: bool,
|
|
620
|
+
) -> None:
|
|
621
|
+
"""Add mean/std overlay lines to a histogram."""
|
|
622
|
+
mean = stats.get('mean')
|
|
623
|
+
std = stats.get('std')
|
|
624
|
+
n = stats.get('n', 0)
|
|
625
|
+
subplot_kw = _subplot_kwargs(row, col)
|
|
626
|
+
|
|
627
|
+
if mean is None or not np.isfinite(mean):
|
|
628
|
+
return
|
|
629
|
+
|
|
630
|
+
# Mean line
|
|
631
|
+
vline_kw = dict(
|
|
632
|
+
x=mean,
|
|
633
|
+
line_dash='solid',
|
|
634
|
+
line_color=theme.center_color,
|
|
635
|
+
line_width=2,
|
|
636
|
+
)
|
|
637
|
+
if not is_faceted:
|
|
638
|
+
vline_kw['annotation_text'] = f'Mean: {mean:.3f}'
|
|
639
|
+
vline_kw['annotation_position'] = 'top'
|
|
640
|
+
vline_kw['annotation_font_size'] = theme.annotation_font_size
|
|
641
|
+
fig.add_vline(**vline_kw, **subplot_kw)
|
|
642
|
+
|
|
643
|
+
# Std deviation lines (±1σ, ±2σ, ±3σ)
|
|
644
|
+
if std is None or n < 2 or not np.isfinite(std) or std <= 0:
|
|
645
|
+
return
|
|
646
|
+
|
|
647
|
+
for mult in [1, 2, 3]:
|
|
648
|
+
for sign in [1, -1]:
|
|
649
|
+
vline_kw = dict(
|
|
650
|
+
x=mean + sign * mult * std,
|
|
651
|
+
line_dash='dash',
|
|
652
|
+
line_color='orange',
|
|
653
|
+
line_width=1,
|
|
654
|
+
)
|
|
655
|
+
if not is_faceted:
|
|
656
|
+
label = f'+{mult}σ' if sign > 0 else f'-{mult}σ'
|
|
657
|
+
vline_kw['annotation_text'] = label
|
|
658
|
+
vline_kw['annotation_position'] = 'top'
|
|
659
|
+
vline_kw['annotation_font_size'] = theme.annotation_font_size
|
|
660
|
+
fig.add_vline(**vline_kw, **subplot_kw)
|