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,157 @@
1
+ """Limit line rendering for control charts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import logging
6
+ from typing import TYPE_CHECKING
7
+
8
+ import plotly.graph_objects as go
9
+
10
+ if TYPE_CHECKING:
11
+ import pandas as pd
12
+
13
+ from .themes import ChartTheme
14
+
15
+ logger = logging.getLogger(__name__)
16
+
17
+
18
+ def format_limit_label(limit_name: str, value: float, show_value: bool) -> str:
19
+ """
20
+ Format control limit annotation label.
21
+
22
+ Parameters
23
+ ----------
24
+ limit_name : str
25
+ Name of the limit ('UPL', 'LPL', 'CL')
26
+ value : float
27
+ Numeric value of the limit
28
+ show_value : bool
29
+ Whether to include the numeric value
30
+
31
+ Returns
32
+ -------
33
+ str
34
+ Formatted label like "UPL = 52.34" or just "UPL"
35
+ """
36
+ if show_value:
37
+ if abs(value) >= 100:
38
+ return f'{limit_name} = {value:.1f}'
39
+ elif abs(value) >= 10:
40
+ return f'{limit_name} = {value:.2f}'
41
+ else:
42
+ return f'{limit_name} = {value:.3f}'
43
+ return limit_name
44
+
45
+
46
+ def build_stepped_coordinates(x_vals: list, limit_vals: list) -> tuple[list, list]:
47
+ """
48
+ Build stepped line coordinates from x values and limit values.
49
+
50
+ For each point, draws a horizontal line to the next point's x position,
51
+ then steps up/down to the next point's limit value.
52
+
53
+ Parameters
54
+ ----------
55
+ x_vals : list
56
+ X-axis values
57
+ limit_vals : list
58
+ Limit values at each x position
59
+
60
+ Returns
61
+ -------
62
+ tuple[list, list]
63
+ (x_stepped, y_stepped) coordinate lists for the stepped line
64
+ """
65
+ if len(x_vals) != len(limit_vals):
66
+ raise ValueError(f'x_vals and limit_vals must have equal length: {len(x_vals)} != {len(limit_vals)}')
67
+
68
+ x_stepped = []
69
+ y_stepped = []
70
+
71
+ for i in range(len(x_vals)):
72
+ x_stepped.append(x_vals[i])
73
+ y_stepped.append(limit_vals[i])
74
+
75
+ if i < len(x_vals) - 1:
76
+ x_stepped.append(x_vals[i + 1])
77
+ y_stepped.append(limit_vals[i])
78
+
79
+ return x_stepped, y_stepped
80
+
81
+
82
+ def add_stepped_limit_line(
83
+ fig: go.Figure,
84
+ data: pd.DataFrame,
85
+ x_col: str,
86
+ limit_col: str,
87
+ line_color: str,
88
+ line_dash: str,
89
+ line_width: float,
90
+ limit_name: str,
91
+ theme: ChartTheme,
92
+ row: int | None = None,
93
+ col: int | None = None,
94
+ ) -> go.Figure:
95
+ """
96
+ Add a stepped limit line that follows varying per-row limits.
97
+
98
+ Unified function for both single charts and faceted subplots.
99
+ When row/col are None, adds to the main figure. When provided,
100
+ adds to the specified subplot.
101
+
102
+ Parameters
103
+ ----------
104
+ fig : go.Figure
105
+ Plotly figure to add the line to
106
+ data : pd.DataFrame
107
+ Chart data with limit column
108
+ x_col : str
109
+ Name of x-axis column
110
+ limit_col : str
111
+ Name of limit column ('upl' or 'lpl')
112
+ line_color : str
113
+ Color for the limit line
114
+ line_dash : str
115
+ Dash pattern for the line
116
+ line_width : float
117
+ Width of the line
118
+ limit_name : str
119
+ Name for hover label ('UPL' or 'LPL')
120
+ theme : ChartTheme
121
+ Chart theme for styling
122
+ row : int, optional
123
+ Subplot row (1-indexed). None for single charts.
124
+ col : int, optional
125
+ Subplot column (1-indexed). None for single charts.
126
+
127
+ Returns
128
+ -------
129
+ go.Figure
130
+ The figure, with the stepped limit line added (or unchanged if
131
+ ``limit_col`` is not present in the data).
132
+ """
133
+ if limit_col not in data.columns:
134
+ logger.debug("Limit column '%s' not found; skipping stepped limit line", limit_col)
135
+ return fig
136
+
137
+ x_vals = data[x_col].tolist() if x_col in data.columns else data.index.tolist()
138
+ limit_vals = data[limit_col].tolist()
139
+
140
+ x_stepped, y_stepped = build_stepped_coordinates(x_vals, limit_vals)
141
+
142
+ scatter = go.Scatter(
143
+ x=x_stepped,
144
+ y=y_stepped,
145
+ mode='lines',
146
+ name=f'{limit_name} (varies)',
147
+ line=dict(color=line_color, dash=line_dash, width=line_width),
148
+ hovertemplate=f'{limit_name}: %{{y:.3f}}<extra></extra>',
149
+ showlegend=False,
150
+ )
151
+
152
+ if row is not None and col is not None:
153
+ fig.add_trace(scatter, row=row, col=col)
154
+ else:
155
+ fig.add_trace(scatter)
156
+
157
+ return fig
@@ -0,0 +1,395 @@
1
+ """
2
+ Maximum Information Analysis visualization.
3
+
4
+ Combined XmR + percentage histogram layout for R2 residuals,
5
+ following the ``capability_chart.py`` pattern.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import TYPE_CHECKING
11
+
12
+ import plotly.graph_objects as go
13
+ from plotly.subplots import make_subplots
14
+
15
+ from ..exceptions import ValidationError
16
+
17
+ if TYPE_CHECKING:
18
+ from ..maximum_information import MaximumInformationResult
19
+ from .themes import ChartTheme
20
+
21
+
22
+ def create_maximum_information_chart(
23
+ result: MaximumInformationResult,
24
+ *,
25
+ view: str = 'combined',
26
+ bins: int = 10,
27
+ theme: ChartTheme | None = None,
28
+ width: int = 900,
29
+ height: int = 700,
30
+ title: str | None = None,
31
+ ) -> go.Figure:
32
+ """
33
+ Create a Maximum Information Analysis chart.
34
+
35
+ Parameters
36
+ ----------
37
+ result : MaximumInformationResult
38
+ Result from ``study.maximum_information()``.
39
+ view : str, default ``'combined'``
40
+ ``'combined'`` — Individuals chart + percentage histogram.
41
+ ``'xmr'`` — Individuals chart of R2 only.
42
+ ``'histogram'`` — Percentage histogram only.
43
+ bins : int, default 10
44
+ Number of histogram bins.
45
+ theme : ChartTheme, optional
46
+ Visual theme. Defaults to the ``processbehavior`` theme.
47
+ width, height : int
48
+ Figure dimensions in pixels.
49
+ title : str, optional
50
+ Override chart title.
51
+
52
+ Returns
53
+ -------
54
+ go.Figure
55
+ """
56
+ if view not in ('combined', 'xmr', 'histogram'):
57
+ raise ValidationError(f"view must be 'combined', 'xmr', or 'histogram', got {view!r}")
58
+
59
+ if theme is None or isinstance(theme, str):
60
+ from .themes import get_theme
61
+
62
+ theme = get_theme(theme if isinstance(theme, str) else 'processbehavior')
63
+
64
+ default_title = 'Maximum Information Analysis of R2 Residuals'
65
+ chart_title = title if title is not None else default_title
66
+
67
+ if view == 'combined':
68
+ return _render_combined(result, theme, chart_title, width, height, bins)
69
+ elif view == 'xmr':
70
+ return _render_xmr(result, theme, chart_title, width, height)
71
+ else:
72
+ return _render_histogram(result, theme, chart_title, width, height, bins)
73
+
74
+
75
+ # ---------------------------------------------------------------------------
76
+ # Combined: XmR (top two rows) + Histogram (bottom)
77
+ # ---------------------------------------------------------------------------
78
+
79
+
80
+ def _render_combined(
81
+ result: MaximumInformationResult,
82
+ theme: ChartTheme,
83
+ title: str,
84
+ width: int,
85
+ height: int,
86
+ bins: int = 10,
87
+ ) -> go.Figure:
88
+ fig = make_subplots(
89
+ rows=2,
90
+ cols=1,
91
+ row_heights=[0.5, 0.5],
92
+ subplot_titles=['XmR', 'Distribution'],
93
+ vertical_spacing=0.14,
94
+ )
95
+
96
+ _add_individuals_panel(fig, result, theme, row=1, col=1)
97
+ _add_histogram_panel(fig, result, theme, row=2, col=1, bins=bins)
98
+
99
+ fig.update_layout(**theme.to_layout_dict())
100
+ fig.update_layout(
101
+ title=title,
102
+ width=width,
103
+ height=height,
104
+ showlegend=False,
105
+ )
106
+
107
+ return fig
108
+
109
+
110
+ # ---------------------------------------------------------------------------
111
+ # XmR only (individuals chart)
112
+ # ---------------------------------------------------------------------------
113
+
114
+
115
+ def _render_xmr(
116
+ result: MaximumInformationResult,
117
+ theme: ChartTheme,
118
+ title: str,
119
+ width: int,
120
+ height: int,
121
+ ) -> go.Figure:
122
+ fig = go.Figure()
123
+
124
+ _add_individuals_panel(fig, result, theme)
125
+
126
+ fig.update_layout(**theme.to_layout_dict())
127
+ fig.update_layout(
128
+ title=title,
129
+ width=width,
130
+ height=height,
131
+ showlegend=False,
132
+ )
133
+
134
+ return fig
135
+
136
+
137
+ # ---------------------------------------------------------------------------
138
+ # Histogram only
139
+ # ---------------------------------------------------------------------------
140
+
141
+
142
+ def _render_histogram(
143
+ result: MaximumInformationResult,
144
+ theme: ChartTheme,
145
+ title: str,
146
+ width: int,
147
+ height: int,
148
+ bins: int = 10,
149
+ ) -> go.Figure:
150
+ fig = go.Figure()
151
+
152
+ _add_histogram_panel(fig, result, theme, bins=bins)
153
+
154
+ fig.update_layout(**theme.to_layout_dict())
155
+ fig.update_layout(
156
+ title=title,
157
+ width=width,
158
+ height=height,
159
+ showlegend=False,
160
+ )
161
+
162
+ return fig
163
+
164
+
165
+ # ---------------------------------------------------------------------------
166
+ # Panel renderers
167
+ # ---------------------------------------------------------------------------
168
+
169
+
170
+ def _add_individuals_panel(
171
+ fig: go.Figure,
172
+ result: MaximumInformationResult,
173
+ theme: ChartTheme,
174
+ row: int | None = None,
175
+ col: int | None = None,
176
+ ) -> None:
177
+ """Render the X (individuals) panel of the XmR chart."""
178
+ info = result._xmr_chart_info
179
+ data = info['data']
180
+ stats = info['statistics']
181
+ x = data['obs']
182
+ y = data['R2']
183
+
184
+ subplot_kw = {}
185
+ if row is not None and col is not None:
186
+ subplot_kw = {'row': row, 'col': col}
187
+
188
+ # Data trace
189
+ fig.add_trace(
190
+ go.Scatter(
191
+ x=x,
192
+ y=y,
193
+ mode='lines+markers',
194
+ name='R2',
195
+ marker=dict(size=5, color=theme.data_color),
196
+ line=dict(color=theme.data_color, width=1),
197
+ hovertemplate='%{x}<br>%{y:.4f}<extra></extra>',
198
+ showlegend=False,
199
+ ),
200
+ **subplot_kw,
201
+ )
202
+
203
+ # Signal markers
204
+ signals = data[data['beyond_limits'] != 0]
205
+ if not signals.empty:
206
+ fig.add_trace(
207
+ go.Scatter(
208
+ x=signals['obs'],
209
+ y=signals['R2'],
210
+ mode='markers',
211
+ name='Signal',
212
+ marker=dict(
213
+ size=theme.signal_marker_size,
214
+ color=theme.signal_color,
215
+ symbol=theme.signal_marker_symbol,
216
+ line=dict(
217
+ width=theme.signal_marker_line_width,
218
+ color=theme.signal_marker_line_color,
219
+ ),
220
+ ),
221
+ showlegend=False,
222
+ hovertemplate='Signal<br>%{x}<br>%{y:.4f}<extra></extra>',
223
+ ),
224
+ **subplot_kw,
225
+ )
226
+
227
+ if row is not None and col is not None:
228
+ # Limit lines (subplot)
229
+ _add_hline(fig, stats['x_mean'], theme.center_color, 'solid', 2, row, col)
230
+ _add_hline(fig, stats['x_upl'], theme.ucl_color, 'dash', 1.5, row, col)
231
+ _add_hline(fig, stats['x_lpl'], theme.lcl_color, 'dash', 1.5, row, col)
232
+
233
+ # Limit annotations
234
+ r = result.round_to
235
+ _add_limit_annotation(fig, f'UPL={round(stats["x_upl"], r)}', stats['x_upl'], theme, row, col)
236
+ _add_limit_annotation(fig, f'CL={round(stats["x_mean"], r)}', stats['x_mean'], theme, row, col)
237
+ _add_limit_annotation(fig, f'LPL={round(stats["x_lpl"], r)}', stats['x_lpl'], theme, row, col)
238
+
239
+ fig.update_yaxes(title_text='R2', row=row, col=col)
240
+ else:
241
+ # Standalone figure
242
+ fig.add_hline(y=stats['x_mean'], line_color=theme.center_color, line_dash='solid', line_width=2)
243
+ fig.add_hline(y=stats['x_upl'], line_color=theme.ucl_color, line_dash='dash', line_width=1.5)
244
+ fig.add_hline(y=stats['x_lpl'], line_color=theme.lcl_color, line_dash='dash', line_width=1.5)
245
+
246
+ r = result.round_to
247
+ for text, yval in [
248
+ (f'UPL={round(stats["x_upl"], r)}', stats['x_upl']),
249
+ (f'CL={round(stats["x_mean"], r)}', stats['x_mean']),
250
+ (f'LPL={round(stats["x_lpl"], r)}', stats['x_lpl']),
251
+ ]:
252
+ fig.add_annotation(
253
+ x=1.0,
254
+ xref='x domain',
255
+ y=yval,
256
+ yref='y',
257
+ text=text,
258
+ showarrow=False,
259
+ xanchor='left',
260
+ font=dict(size=theme.annotation_font_size, color='gray'),
261
+ )
262
+
263
+ fig.update_yaxes(title_text='R2')
264
+ fig.update_xaxes(title_text='Observation')
265
+
266
+
267
+ def _add_histogram_panel(
268
+ fig: go.Figure,
269
+ result: MaximumInformationResult,
270
+ theme: ChartTheme,
271
+ row: int | None = None,
272
+ col: int | None = None,
273
+ bins: int = 10,
274
+ ) -> None:
275
+ """Render the percentage histogram of R2 with NPL x-axis extension."""
276
+ info = result._histogram_chart_info
277
+ data = info['data']
278
+ r2_values = data['R2'].to_numpy()
279
+
280
+ trace_kwargs = dict(
281
+ x=r2_values,
282
+ nbinsx=bins,
283
+ histnorm='percent',
284
+ marker_color=theme.data_color,
285
+ opacity=0.75,
286
+ name='R2',
287
+ showlegend=False,
288
+ hovertemplate='Range: %{x}<br>Percent: %{y:.1f}%<extra></extra>',
289
+ )
290
+
291
+ if row is not None and col is not None:
292
+ fig.add_trace(go.Histogram(**trace_kwargs), row=row, col=col)
293
+ else:
294
+ fig.add_trace(go.Histogram(**trace_kwargs))
295
+
296
+ # Mean line
297
+ stats = result._xmr_chart_info['statistics']
298
+ r2_mean = stats['x_mean']
299
+ upl = stats['x_upl']
300
+ lpl = stats['x_lpl']
301
+
302
+ subplot_kw = {}
303
+ if row is not None and col is not None:
304
+ subplot_kw = {'row': row, 'col': col}
305
+
306
+ fig.add_vline(
307
+ x=r2_mean,
308
+ line_color=theme.center_color,
309
+ line_width=2,
310
+ line_dash='solid',
311
+ **subplot_kw,
312
+ )
313
+
314
+ # NPL lines on histogram
315
+ fig.add_vline(
316
+ x=upl,
317
+ line_color=theme.ucl_color,
318
+ line_width=1.5,
319
+ line_dash='dash',
320
+ **subplot_kw,
321
+ )
322
+ fig.add_vline(
323
+ x=lpl,
324
+ line_color=theme.lcl_color,
325
+ line_width=1.5,
326
+ line_dash='dash',
327
+ **subplot_kw,
328
+ )
329
+
330
+ # Extend x-axis to NPL range
331
+ npl_range = upl - lpl
332
+ padding = npl_range * 0.05
333
+ x_min = lpl - padding
334
+ x_max = upl + padding
335
+
336
+ if row is not None and col is not None:
337
+ fig.update_xaxes(range=[x_min, x_max], row=row, col=col)
338
+ fig.update_yaxes(title_text='Percent', row=row, col=col)
339
+ fig.update_xaxes(title_text='R2', row=row, col=col)
340
+ else:
341
+ fig.update_xaxes(range=[x_min, x_max], title_text='R2')
342
+ fig.update_yaxes(title_text='Percent')
343
+
344
+ fig.update_layout(bargap=0.05)
345
+
346
+
347
+ # ---------------------------------------------------------------------------
348
+ # Helpers
349
+ # ---------------------------------------------------------------------------
350
+
351
+
352
+ def _add_hline(
353
+ fig: go.Figure,
354
+ y: float,
355
+ color: str,
356
+ dash: str,
357
+ width: float,
358
+ row: int,
359
+ col: int,
360
+ ) -> None:
361
+ """Add a horizontal line to a subplot."""
362
+ fig.add_hline(
363
+ y=y,
364
+ line_color=color,
365
+ line_dash=dash,
366
+ line_width=width,
367
+ row=row,
368
+ col=col,
369
+ )
370
+
371
+
372
+ def _add_limit_annotation(
373
+ fig: go.Figure,
374
+ text: str,
375
+ y: float,
376
+ theme: ChartTheme,
377
+ row: int,
378
+ col: int,
379
+ ) -> None:
380
+ """Add a right-aligned limit annotation."""
381
+ # Determine axis refs for the subplot
382
+ subplot_idx = row # single-column layout: row index = subplot index
383
+ yref = 'y' if subplot_idx == 1 else f'y{subplot_idx}'
384
+ xref = 'x domain' if subplot_idx == 1 else f'x{subplot_idx} domain'
385
+
386
+ fig.add_annotation(
387
+ x=1.0,
388
+ xref=xref,
389
+ y=y,
390
+ yref=yref,
391
+ text=text,
392
+ showarrow=False,
393
+ xanchor='left',
394
+ font=dict(size=theme.annotation_font_size, color='gray'),
395
+ )