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,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