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,211 @@
|
|
|
1
|
+
"""HTML report generation.
|
|
2
|
+
|
|
3
|
+
Extracted from plotter.py to isolate report building from chart rendering.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import html
|
|
9
|
+
import logging
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import TYPE_CHECKING
|
|
12
|
+
|
|
13
|
+
from .themes import ChartTheme, get_theme
|
|
14
|
+
|
|
15
|
+
if TYPE_CHECKING:
|
|
16
|
+
from ..analysis_result import AnalysisResult
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def generate_report(
|
|
22
|
+
result: AnalysisResult,
|
|
23
|
+
filepath: str,
|
|
24
|
+
include_charts: bool = True,
|
|
25
|
+
include_residuals: bool = True,
|
|
26
|
+
include_effects: bool = True,
|
|
27
|
+
include_summary: bool = True,
|
|
28
|
+
theme: str | ChartTheme = 'processbehavior',
|
|
29
|
+
width: int = 1200,
|
|
30
|
+
title: str | None = None,
|
|
31
|
+
) -> None:
|
|
32
|
+
"""Generate a comprehensive HTML report with all visualizations.
|
|
33
|
+
|
|
34
|
+
Creates a single-page HTML file with analysis summary, control charts,
|
|
35
|
+
residual diagnostics, and effects analysis.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
result : AnalysisResult
|
|
40
|
+
The analysis result to report on.
|
|
41
|
+
filepath : str
|
|
42
|
+
Output HTML file path.
|
|
43
|
+
include_charts : bool, default True
|
|
44
|
+
Include control charts.
|
|
45
|
+
include_residuals : bool, default True
|
|
46
|
+
Include residual diagnostic plots (if available).
|
|
47
|
+
include_effects : bool, default True
|
|
48
|
+
Include effects bar charts (if available).
|
|
49
|
+
include_summary : bool, default True
|
|
50
|
+
Include analysis summary section.
|
|
51
|
+
theme : str or ChartTheme, default 'processbehavior'
|
|
52
|
+
Visual theme for all charts.
|
|
53
|
+
width : int, default 1200
|
|
54
|
+
Width of charts in pixels.
|
|
55
|
+
title : str, optional
|
|
56
|
+
Report title (defaults to "Process Behavior Analysis Report").
|
|
57
|
+
"""
|
|
58
|
+
# Import here to avoid circular import at module level
|
|
59
|
+
from .plotter import Plotter
|
|
60
|
+
|
|
61
|
+
plotter = Plotter(result)
|
|
62
|
+
|
|
63
|
+
sections: list[str] = []
|
|
64
|
+
report_title = title or 'Process Behavior Analysis Report'
|
|
65
|
+
esc = html.escape
|
|
66
|
+
|
|
67
|
+
# Resolve theme for plotter calls
|
|
68
|
+
resolved_theme = get_theme(theme) if isinstance(theme, str) else theme
|
|
69
|
+
|
|
70
|
+
# Summary section
|
|
71
|
+
if include_summary:
|
|
72
|
+
sections.append(_build_summary_section(result, esc))
|
|
73
|
+
|
|
74
|
+
# Control charts section
|
|
75
|
+
if include_charts:
|
|
76
|
+
fig = plotter.plot(theme=resolved_theme, width=width, height=500)
|
|
77
|
+
chart_html = fig.figure.to_html(full_html=False, include_plotlyjs=False)
|
|
78
|
+
sections.append(f"""
|
|
79
|
+
<div class="section">
|
|
80
|
+
<h2>Control Charts</h2>
|
|
81
|
+
{chart_html}
|
|
82
|
+
</div>
|
|
83
|
+
""")
|
|
84
|
+
|
|
85
|
+
# Residuals section
|
|
86
|
+
if include_residuals and result.has_residuals:
|
|
87
|
+
from .residuals import plot_residuals
|
|
88
|
+
|
|
89
|
+
fig = plot_residuals(result, theme=resolved_theme, width=width, height=350)
|
|
90
|
+
residual_html = fig.figure.to_html(full_html=False, include_plotlyjs=False)
|
|
91
|
+
sections.append(f"""
|
|
92
|
+
<div class="section">
|
|
93
|
+
<h2>Residual Diagnostics</h2>
|
|
94
|
+
<p>R1 residuals showing process variation over time.</p>
|
|
95
|
+
{residual_html}
|
|
96
|
+
</div>
|
|
97
|
+
""")
|
|
98
|
+
|
|
99
|
+
# Effects section
|
|
100
|
+
if include_effects and result.has_effects:
|
|
101
|
+
try:
|
|
102
|
+
fig = plotter.plot_effects(
|
|
103
|
+
effect_type='all',
|
|
104
|
+
theme=resolved_theme,
|
|
105
|
+
width=width,
|
|
106
|
+
height=400,
|
|
107
|
+
)
|
|
108
|
+
effects_html = fig.figure.to_html(full_html=False, include_plotlyjs=False)
|
|
109
|
+
sections.append(f"""
|
|
110
|
+
<div class="section">
|
|
111
|
+
<h2>Main Effects Analysis</h2>
|
|
112
|
+
<p>Factor and time effects showing contribution to process variation.</p>
|
|
113
|
+
{effects_html}
|
|
114
|
+
</div>
|
|
115
|
+
""")
|
|
116
|
+
except (ValueError, Exception):
|
|
117
|
+
pass # No effects to plot
|
|
118
|
+
|
|
119
|
+
# Build full HTML
|
|
120
|
+
html_content = _build_html_page(report_title, sections, esc)
|
|
121
|
+
Path(filepath).write_text(html_content)
|
|
122
|
+
logger.info(f'Report generated: {filepath}')
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _build_summary_section(result, esc):
|
|
126
|
+
"""Build the HTML summary table."""
|
|
127
|
+
summary = result.summary
|
|
128
|
+
has_res = 'Yes' if summary['has_residuals'] else 'No'
|
|
129
|
+
has_eff = 'Yes' if summary['has_effects'] else 'No'
|
|
130
|
+
return f"""
|
|
131
|
+
<div class="section">
|
|
132
|
+
<h2>Analysis Summary</h2>
|
|
133
|
+
<table class="summary-table">
|
|
134
|
+
<tr><td><strong>ADS</strong></td><td>{esc(str(summary['analytical_sds']))} - {
|
|
135
|
+
esc(str(summary['analytical_sds_description']))
|
|
136
|
+
}</td></tr>
|
|
137
|
+
<tr><td><strong>Response Variable</strong></td><td>{esc(str(summary['response_var']))}</td></tr>
|
|
138
|
+
<tr><td><strong>Observations</strong></td><td>{esc(str(summary['n_observations']))}</td></tr>
|
|
139
|
+
<tr><td><strong>Charts</strong></td><td>{esc(', '.join(str(c) for c in summary['chart_types']))}</td></tr>
|
|
140
|
+
<tr><td><strong>Signals Detected</strong></td><td>{esc(str(summary['n_signals_total']))}</td></tr>
|
|
141
|
+
<tr><td><strong>Has Residuals</strong></td><td>{has_res}</td></tr>
|
|
142
|
+
<tr><td><strong>Has Effects</strong></td><td>{has_eff}</td></tr>
|
|
143
|
+
</table>
|
|
144
|
+
</div>
|
|
145
|
+
"""
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _build_html_page(report_title, sections, esc):
|
|
149
|
+
"""Build the full HTML page."""
|
|
150
|
+
return f"""
|
|
151
|
+
<!DOCTYPE html>
|
|
152
|
+
<html>
|
|
153
|
+
<head>
|
|
154
|
+
<meta charset="utf-8">
|
|
155
|
+
<title>{esc(report_title)}</title>
|
|
156
|
+
<script src="https://cdn.plot.ly/plotly-latest.min.js"></script>
|
|
157
|
+
<style>
|
|
158
|
+
body {{
|
|
159
|
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
|
160
|
+
max-width: 1400px;
|
|
161
|
+
margin: 0 auto;
|
|
162
|
+
padding: 20px;
|
|
163
|
+
background-color: #f5f5f5;
|
|
164
|
+
}}
|
|
165
|
+
h1 {{
|
|
166
|
+
color: #333;
|
|
167
|
+
border-bottom: 2px solid #4a90a4;
|
|
168
|
+
padding-bottom: 10px;
|
|
169
|
+
}}
|
|
170
|
+
h2 {{
|
|
171
|
+
color: #4a90a4;
|
|
172
|
+
margin-top: 30px;
|
|
173
|
+
}}
|
|
174
|
+
.section {{
|
|
175
|
+
background: white;
|
|
176
|
+
padding: 20px;
|
|
177
|
+
margin: 20px 0;
|
|
178
|
+
border-radius: 8px;
|
|
179
|
+
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
|
180
|
+
}}
|
|
181
|
+
.summary-table {{
|
|
182
|
+
border-collapse: collapse;
|
|
183
|
+
width: 100%;
|
|
184
|
+
max-width: 600px;
|
|
185
|
+
}}
|
|
186
|
+
.summary-table td {{
|
|
187
|
+
padding: 8px 12px;
|
|
188
|
+
border-bottom: 1px solid #eee;
|
|
189
|
+
}}
|
|
190
|
+
.summary-table tr:last-child td {{
|
|
191
|
+
border-bottom: none;
|
|
192
|
+
}}
|
|
193
|
+
.footer {{
|
|
194
|
+
text-align: center;
|
|
195
|
+
color: #888;
|
|
196
|
+
font-size: 12px;
|
|
197
|
+
margin-top: 40px;
|
|
198
|
+
padding-top: 20px;
|
|
199
|
+
border-top: 1px solid #ddd;
|
|
200
|
+
}}
|
|
201
|
+
</style>
|
|
202
|
+
</head>
|
|
203
|
+
<body>
|
|
204
|
+
<h1>{esc(report_title)}</h1>
|
|
205
|
+
{''.join(sections)}
|
|
206
|
+
<div class="footer">
|
|
207
|
+
Generated with ProcessBehavior - Statistical Process Control for Python
|
|
208
|
+
</div>
|
|
209
|
+
</body>
|
|
210
|
+
</html>
|
|
211
|
+
"""
|
|
@@ -0,0 +1,282 @@
|
|
|
1
|
+
"""Residual diagnostic plots.
|
|
2
|
+
|
|
3
|
+
Extracted from plotter.py to isolate residual visualization from
|
|
4
|
+
control chart rendering.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from typing import TYPE_CHECKING
|
|
10
|
+
|
|
11
|
+
import numpy as np
|
|
12
|
+
import plotly.graph_objects as go
|
|
13
|
+
from plotly.subplots import make_subplots
|
|
14
|
+
|
|
15
|
+
from ..exceptions import ValidationError
|
|
16
|
+
from ._math import normal_pdf, normal_ppf
|
|
17
|
+
from .control_chart import ControlChartFigure
|
|
18
|
+
from .themes import ChartTheme, apply_theme, get_theme
|
|
19
|
+
|
|
20
|
+
if TYPE_CHECKING:
|
|
21
|
+
from ..analysis_result import AnalysisResult
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def plot_residuals(
|
|
25
|
+
result: AnalysisResult,
|
|
26
|
+
residual_type: str = 'R1',
|
|
27
|
+
plot_type: str = 'all',
|
|
28
|
+
theme: str | ChartTheme = 'processbehavior',
|
|
29
|
+
width: int = 1200,
|
|
30
|
+
height: int = 400,
|
|
31
|
+
) -> ControlChartFigure:
|
|
32
|
+
"""Create residual diagnostic plots.
|
|
33
|
+
|
|
34
|
+
Visualizes VAS residuals to assess process behavior and identify
|
|
35
|
+
patterns that may indicate non-random variation.
|
|
36
|
+
|
|
37
|
+
Parameters
|
|
38
|
+
----------
|
|
39
|
+
result : AnalysisResult
|
|
40
|
+
The analysis result containing residuals.
|
|
41
|
+
residual_type : str, default 'R1'
|
|
42
|
+
Which residual to plot ('R1', 'R2', 'R3', 'R4', 'R5').
|
|
43
|
+
plot_type : str, default 'all'
|
|
44
|
+
Type of diagnostic plot:
|
|
45
|
+
- 'histogram': Distribution of residuals
|
|
46
|
+
- 'qq': Quantile-quantile plot for normality
|
|
47
|
+
- 'sequence': Residuals vs. observation order
|
|
48
|
+
- 'all': All three plots in subplots
|
|
49
|
+
theme : str or ChartTheme, default 'processbehavior'
|
|
50
|
+
Visual theme.
|
|
51
|
+
width : int, default 1200
|
|
52
|
+
Figure width in pixels.
|
|
53
|
+
height : int, default 400
|
|
54
|
+
Figure height in pixels (per row for 'all').
|
|
55
|
+
|
|
56
|
+
Returns
|
|
57
|
+
-------
|
|
58
|
+
ControlChartFigure
|
|
59
|
+
Interactive figure with residual diagnostics.
|
|
60
|
+
|
|
61
|
+
Raises
|
|
62
|
+
------
|
|
63
|
+
ValidationError
|
|
64
|
+
If residuals not available or invalid residual type.
|
|
65
|
+
"""
|
|
66
|
+
if not result.has_residuals:
|
|
67
|
+
raise ValidationError(
|
|
68
|
+
'Residuals not available for this analysis.\nResiduals require SDS >= 1 (replicated observations).'
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
# One lookup, shared with get_residual: it finds stored residuals on the study-level
|
|
72
|
+
# snapshot and request residuals (R6/RCR6) on this result's dataset. Reading
|
|
73
|
+
# result.residuals directly here is what made this raise "not found" about a column
|
|
74
|
+
# the result was holding.
|
|
75
|
+
r_data = result.get_residual(residual_type).dropna()
|
|
76
|
+
|
|
77
|
+
theme = get_theme(theme) if isinstance(theme, str) else theme
|
|
78
|
+
|
|
79
|
+
if plot_type == 'all':
|
|
80
|
+
fig = _plot_all_diagnostics(r_data, residual_type, theme, width, height)
|
|
81
|
+
elif plot_type == 'histogram':
|
|
82
|
+
fig = _plot_histogram(r_data, residual_type, theme, width, height)
|
|
83
|
+
elif plot_type == 'qq':
|
|
84
|
+
fig = _plot_qq(r_data, residual_type, theme, width, height)
|
|
85
|
+
elif plot_type == 'sequence':
|
|
86
|
+
fig = _plot_sequence(r_data, residual_type, theme, width, height)
|
|
87
|
+
else:
|
|
88
|
+
raise ValidationError(f"Invalid plot_type: '{plot_type}'.\nOptions: 'all', 'histogram', 'qq', 'sequence'")
|
|
89
|
+
|
|
90
|
+
fig = apply_theme(fig, theme)
|
|
91
|
+
return ControlChartFigure(fig, result)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
# Internal plot builders
|
|
96
|
+
# ---------------------------------------------------------------------------
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _plot_all_diagnostics(r_data, residual_type, theme, width, height):
|
|
100
|
+
"""3-panel diagnostic: histogram + Q-Q + sequence."""
|
|
101
|
+
fig = make_subplots(
|
|
102
|
+
rows=1,
|
|
103
|
+
cols=3,
|
|
104
|
+
subplot_titles=['Histogram', 'Q-Q Plot', 'Sequence Plot'],
|
|
105
|
+
horizontal_spacing=0.08,
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# 1. Histogram
|
|
109
|
+
fig.add_trace(
|
|
110
|
+
go.Histogram(
|
|
111
|
+
x=r_data,
|
|
112
|
+
name='Residuals',
|
|
113
|
+
marker_color=theme.data_color,
|
|
114
|
+
opacity=0.7,
|
|
115
|
+
showlegend=False,
|
|
116
|
+
),
|
|
117
|
+
row=1,
|
|
118
|
+
col=1,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
# Normal curve overlay
|
|
122
|
+
x_range = np.linspace(r_data.min(), r_data.max(), 100)
|
|
123
|
+
mu, sigma = r_data.mean(), r_data.std()
|
|
124
|
+
y_normal = np.array([normal_pdf(x, mu, sigma) for x in x_range])
|
|
125
|
+
y_normal = y_normal * len(r_data) * (r_data.max() - r_data.min()) / 20
|
|
126
|
+
|
|
127
|
+
fig.add_trace(
|
|
128
|
+
go.Scatter(
|
|
129
|
+
x=x_range,
|
|
130
|
+
y=y_normal,
|
|
131
|
+
mode='lines',
|
|
132
|
+
name='Normal',
|
|
133
|
+
line=dict(color=theme.center_color, width=2, dash='dash'),
|
|
134
|
+
showlegend=False,
|
|
135
|
+
),
|
|
136
|
+
row=1,
|
|
137
|
+
col=1,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
# 2. Q-Q Plot
|
|
141
|
+
probs = np.linspace(0.01, 0.99, len(r_data))
|
|
142
|
+
theoretical_q = np.array([normal_ppf(p) for p in probs])
|
|
143
|
+
sample_q = np.sort(r_data.values)
|
|
144
|
+
|
|
145
|
+
fig.add_trace(
|
|
146
|
+
go.Scatter(
|
|
147
|
+
x=theoretical_q,
|
|
148
|
+
y=sample_q,
|
|
149
|
+
mode='markers',
|
|
150
|
+
name='Q-Q',
|
|
151
|
+
marker=dict(color=theme.data_color, size=5),
|
|
152
|
+
showlegend=False,
|
|
153
|
+
),
|
|
154
|
+
row=1,
|
|
155
|
+
col=2,
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
# Reference line
|
|
159
|
+
qq_min = min(theoretical_q.min(), sample_q.min())
|
|
160
|
+
qq_max = max(theoretical_q.max(), sample_q.max())
|
|
161
|
+
fig.add_trace(
|
|
162
|
+
go.Scatter(
|
|
163
|
+
x=[qq_min, qq_max],
|
|
164
|
+
y=[qq_min * r_data.std() + r_data.mean(), qq_max * r_data.std() + r_data.mean()],
|
|
165
|
+
mode='lines',
|
|
166
|
+
name='Reference',
|
|
167
|
+
line=dict(color=theme.ucl_color, dash='dash'),
|
|
168
|
+
showlegend=False,
|
|
169
|
+
),
|
|
170
|
+
row=1,
|
|
171
|
+
col=2,
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
# 3. Sequence plot
|
|
175
|
+
fig.add_trace(
|
|
176
|
+
go.Scatter(
|
|
177
|
+
x=list(range(len(r_data))),
|
|
178
|
+
y=r_data.values,
|
|
179
|
+
mode='lines+markers',
|
|
180
|
+
name='Residuals',
|
|
181
|
+
marker=dict(size=4, color=theme.data_color),
|
|
182
|
+
line=dict(color=theme.data_color, width=1),
|
|
183
|
+
showlegend=False,
|
|
184
|
+
),
|
|
185
|
+
row=1,
|
|
186
|
+
col=3,
|
|
187
|
+
)
|
|
188
|
+
fig.add_hline(y=0, line_dash='dash', line_color=theme.center_color, row=1, col=3)
|
|
189
|
+
|
|
190
|
+
fig.update_layout(
|
|
191
|
+
title=f'{residual_type} Residual Diagnostics',
|
|
192
|
+
width=width,
|
|
193
|
+
height=height,
|
|
194
|
+
showlegend=False,
|
|
195
|
+
)
|
|
196
|
+
fig.update_xaxes(title_text='Residual Value', row=1, col=1)
|
|
197
|
+
fig.update_yaxes(title_text='Frequency', row=1, col=1)
|
|
198
|
+
fig.update_xaxes(title_text='Theoretical Quantiles', row=1, col=2)
|
|
199
|
+
fig.update_yaxes(title_text='Sample Quantiles', row=1, col=2)
|
|
200
|
+
fig.update_xaxes(title_text='Observation', row=1, col=3)
|
|
201
|
+
fig.update_yaxes(title_text='Residual', row=1, col=3)
|
|
202
|
+
|
|
203
|
+
return fig
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _plot_histogram(r_data, residual_type, theme, width, height):
|
|
207
|
+
fig = go.Figure()
|
|
208
|
+
fig.add_trace(
|
|
209
|
+
go.Histogram(
|
|
210
|
+
x=r_data,
|
|
211
|
+
name='Residuals',
|
|
212
|
+
marker_color=theme.data_color,
|
|
213
|
+
opacity=0.7,
|
|
214
|
+
)
|
|
215
|
+
)
|
|
216
|
+
fig.update_layout(
|
|
217
|
+
title=f'{residual_type} Residual Distribution',
|
|
218
|
+
xaxis_title='Residual Value',
|
|
219
|
+
yaxis_title='Frequency',
|
|
220
|
+
width=width,
|
|
221
|
+
height=height,
|
|
222
|
+
)
|
|
223
|
+
return fig
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _plot_qq(r_data, residual_type, theme, width, height):
|
|
227
|
+
probs = np.linspace(0.01, 0.99, len(r_data))
|
|
228
|
+
theoretical_q = np.array([normal_ppf(p) for p in probs])
|
|
229
|
+
sample_q = np.sort(r_data.values)
|
|
230
|
+
|
|
231
|
+
fig = go.Figure()
|
|
232
|
+
fig.add_trace(
|
|
233
|
+
go.Scatter(
|
|
234
|
+
x=theoretical_q,
|
|
235
|
+
y=sample_q,
|
|
236
|
+
mode='markers',
|
|
237
|
+
name='Data',
|
|
238
|
+
marker=dict(color=theme.data_color, size=6),
|
|
239
|
+
)
|
|
240
|
+
)
|
|
241
|
+
qq_min = min(theoretical_q.min(), sample_q.min())
|
|
242
|
+
qq_max = max(theoretical_q.max(), sample_q.max())
|
|
243
|
+
fig.add_trace(
|
|
244
|
+
go.Scatter(
|
|
245
|
+
x=[qq_min, qq_max],
|
|
246
|
+
y=[qq_min * r_data.std() + r_data.mean(), qq_max * r_data.std() + r_data.mean()],
|
|
247
|
+
mode='lines',
|
|
248
|
+
name='Normal Reference',
|
|
249
|
+
line=dict(color=theme.ucl_color, dash='dash'),
|
|
250
|
+
)
|
|
251
|
+
)
|
|
252
|
+
fig.update_layout(
|
|
253
|
+
title=f'{residual_type} Q-Q Plot',
|
|
254
|
+
xaxis_title='Theoretical Quantiles',
|
|
255
|
+
yaxis_title='Sample Quantiles',
|
|
256
|
+
width=width,
|
|
257
|
+
height=height,
|
|
258
|
+
)
|
|
259
|
+
return fig
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _plot_sequence(r_data, residual_type, theme, width, height):
|
|
263
|
+
fig = go.Figure()
|
|
264
|
+
fig.add_trace(
|
|
265
|
+
go.Scatter(
|
|
266
|
+
x=list(range(len(r_data))),
|
|
267
|
+
y=r_data.values,
|
|
268
|
+
mode='lines+markers',
|
|
269
|
+
name='Residuals',
|
|
270
|
+
marker=dict(size=5, color=theme.data_color),
|
|
271
|
+
line=dict(color=theme.data_color, width=1),
|
|
272
|
+
)
|
|
273
|
+
)
|
|
274
|
+
fig.add_hline(y=0, line_dash='dash', line_color=theme.center_color)
|
|
275
|
+
fig.update_layout(
|
|
276
|
+
title=f'{residual_type} Residual Sequence Plot',
|
|
277
|
+
xaxis_title='Observation',
|
|
278
|
+
yaxis_title='Residual Value',
|
|
279
|
+
width=width,
|
|
280
|
+
height=height,
|
|
281
|
+
)
|
|
282
|
+
return fig
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
"""Run rules visualization for control charts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
from typing import TYPE_CHECKING
|
|
7
|
+
|
|
8
|
+
import pandas as pd
|
|
9
|
+
import plotly.graph_objects as go
|
|
10
|
+
|
|
11
|
+
from processbehavior.exceptions import ProcessBehaviorError
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from processbehavior.analysis_result import AnalysisResult
|
|
15
|
+
|
|
16
|
+
from .themes import ChartTheme
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
_RULE_SHORT_NAMES = {
|
|
21
|
+
'rule_2': '2 of 3 in Zone A',
|
|
22
|
+
'rule_3': '4 of 5 in Zone B+',
|
|
23
|
+
'rule_4': '8+ same side',
|
|
24
|
+
'rule_5': 'Trend',
|
|
25
|
+
'rule_6': 'Oscillation',
|
|
26
|
+
'rule_7': 'In Zone C',
|
|
27
|
+
'rule_8': 'Avoiding center',
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def add_run_rules_visualization(
|
|
32
|
+
fig: go.Figure,
|
|
33
|
+
data: pd.DataFrame,
|
|
34
|
+
stats: dict,
|
|
35
|
+
chart_name: str,
|
|
36
|
+
value_col: str,
|
|
37
|
+
x_col: str,
|
|
38
|
+
theme: ChartTheme,
|
|
39
|
+
result: AnalysisResult,
|
|
40
|
+
row: int | None = None,
|
|
41
|
+
col: int | None = None,
|
|
42
|
+
) -> go.Figure:
|
|
43
|
+
"""
|
|
44
|
+
Add visualization for Western Electric run rules (Rules 2-8).
|
|
45
|
+
|
|
46
|
+
Detects rule violations and adds annotations and markers to highlight
|
|
47
|
+
the specific rules that were violated at each observation.
|
|
48
|
+
|
|
49
|
+
Parameters
|
|
50
|
+
----------
|
|
51
|
+
fig : go.Figure
|
|
52
|
+
Plotly figure to add visualizations to
|
|
53
|
+
data : DataFrame
|
|
54
|
+
Chart data
|
|
55
|
+
stats : dict
|
|
56
|
+
Chart statistics
|
|
57
|
+
chart_name : str
|
|
58
|
+
Name of the chart being plotted
|
|
59
|
+
value_col : str
|
|
60
|
+
Name of the value column
|
|
61
|
+
x_col : str
|
|
62
|
+
Name of the x-axis column
|
|
63
|
+
theme : ChartTheme
|
|
64
|
+
Theme with rule colors and styling
|
|
65
|
+
result : AnalysisResult
|
|
66
|
+
Analysis result for signal detection
|
|
67
|
+
row : int, optional
|
|
68
|
+
Row number for faceted plots (1-indexed)
|
|
69
|
+
col : int, optional
|
|
70
|
+
Column number for faceted plots (1-indexed)
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
signal_result = result.detect_signals(chart=chart_name)
|
|
74
|
+
|
|
75
|
+
if not signal_result.has_signals:
|
|
76
|
+
return fig
|
|
77
|
+
|
|
78
|
+
violations = signal_result.violations
|
|
79
|
+
|
|
80
|
+
# Skip Rule 1 (already handled by highlight_signals)
|
|
81
|
+
violations = violations[violations['rule_name'] != 'rule_1']
|
|
82
|
+
|
|
83
|
+
if violations.empty:
|
|
84
|
+
return fig
|
|
85
|
+
|
|
86
|
+
grouped = violations.groupby('obs_id', observed=True)
|
|
87
|
+
|
|
88
|
+
annotated_points = []
|
|
89
|
+
|
|
90
|
+
for obs_id, obs_violations in grouped:
|
|
91
|
+
if obs_id in data.index:
|
|
92
|
+
obs_data = data.loc[obs_id]
|
|
93
|
+
if isinstance(obs_data, pd.DataFrame):
|
|
94
|
+
logger.debug('Skipping obs_id=%s: MultiIndex expanded to DataFrame', obs_id)
|
|
95
|
+
continue
|
|
96
|
+
else:
|
|
97
|
+
continue
|
|
98
|
+
|
|
99
|
+
x_val = obs_data[x_col] if x_col in data.columns else obs_id
|
|
100
|
+
y_val = obs_data[value_col]
|
|
101
|
+
|
|
102
|
+
rules = obs_violations['rule_name'].unique().tolist()
|
|
103
|
+
rule_nums = [r.split('_')[1] if '_' in r else r for r in rules]
|
|
104
|
+
|
|
105
|
+
annotated_points.append(
|
|
106
|
+
{
|
|
107
|
+
'x': x_val,
|
|
108
|
+
'y': y_val,
|
|
109
|
+
'rules': rules,
|
|
110
|
+
'rule_nums': rule_nums,
|
|
111
|
+
'hover': '<br>'.join([_RULE_SHORT_NAMES.get(r, r) for r in rules]),
|
|
112
|
+
}
|
|
113
|
+
)
|
|
114
|
+
|
|
115
|
+
if not annotated_points:
|
|
116
|
+
return fig
|
|
117
|
+
|
|
118
|
+
x_vals = [p['x'] for p in annotated_points]
|
|
119
|
+
y_vals = [p['y'] for p in annotated_points]
|
|
120
|
+
hover_texts = [f'Rule violations:<br>{p["hover"]}<br>Value: {p["y"]:.3f}' for p in annotated_points]
|
|
121
|
+
|
|
122
|
+
scatter_kwargs = dict(
|
|
123
|
+
x=x_vals,
|
|
124
|
+
y=y_vals,
|
|
125
|
+
mode='markers',
|
|
126
|
+
name='Pattern Signals',
|
|
127
|
+
marker=dict(
|
|
128
|
+
size=theme.data_marker_size,
|
|
129
|
+
color=theme.pattern_signal_color,
|
|
130
|
+
symbol='circle',
|
|
131
|
+
line=dict(width=1, color='darkorange'),
|
|
132
|
+
),
|
|
133
|
+
hovertext=hover_texts,
|
|
134
|
+
hoverinfo='text',
|
|
135
|
+
showlegend=False,
|
|
136
|
+
)
|
|
137
|
+
|
|
138
|
+
if row is not None and col is not None:
|
|
139
|
+
scatter_kwargs['showlegend'] = False
|
|
140
|
+
fig.add_trace(go.Scatter(**scatter_kwargs), row=row, col=col)
|
|
141
|
+
else:
|
|
142
|
+
fig.add_trace(go.Scatter(**scatter_kwargs))
|
|
143
|
+
|
|
144
|
+
return fig
|
|
145
|
+
except (AttributeError, KeyError, ValueError, ProcessBehaviorError) as e:
|
|
146
|
+
logger.info('Skipping run-rule annotations: %s', e)
|
|
147
|
+
return fig
|
|
148
|
+
except Exception:
|
|
149
|
+
logger.exception('Run-rule annotation failed unexpectedly')
|
|
150
|
+
raise
|