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,708 @@
1
+ """
2
+ Excel export functionality for AnalysisResult.
3
+
4
+ This module provides the ExcelExporter class which handles all Excel export
5
+ operations, keeping the AnalysisResult class focused on data access.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import logging
11
+ from pathlib import Path
12
+ from typing import TYPE_CHECKING
13
+
14
+ import pandas as pd
15
+
16
+ if TYPE_CHECKING:
17
+ from ..analysis_result import AnalysisResult
18
+
19
+ logger = logging.getLogger(__name__)
20
+
21
+ # Standard SPC chart type names
22
+ STANDARD_CHART_NAMES = {'Xbar', 'S', 'X', 'mR'}
23
+
24
+
25
+ class ExcelExporter:
26
+ """
27
+ Export AnalysisResult to Excel workbook with multiple tabs.
28
+
29
+ This class encapsulates all Excel export functionality, providing a clean
30
+ separation of concerns from the AnalysisResult data container.
31
+
32
+ Parameters
33
+ ----------
34
+ result : AnalysisResult
35
+ The analysis result to export
36
+
37
+ Examples
38
+ --------
39
+ Direct usage (prefer result.to_excel() for convenience):
40
+
41
+ >>> from processbehavior.exporters import ExcelExporter
42
+ >>> exporter = ExcelExporter(result)
43
+ >>> exporter.export('analysis.xlsx')
44
+
45
+ Via AnalysisResult delegation:
46
+
47
+ >>> result.to_excel('analysis.xlsx')
48
+ """
49
+
50
+ def __init__(self, result: AnalysisResult):
51
+ """Initialize exporter with an AnalysisResult."""
52
+ self.result = result
53
+ # Companion HTML files written by the current export(), so the
54
+ # Visual_Charts tab can name them instead of asserting they exist.
55
+ self._html_exported: list[Path] = []
56
+
57
+ def export(
58
+ self,
59
+ filepath: str,
60
+ include_summary: bool = True,
61
+ include_charts: bool = True,
62
+ include_residuals: bool = True,
63
+ include_effects: bool = True,
64
+ include_interactions: bool = True,
65
+ include_full_dataset: bool = False,
66
+ format_cells: bool = True,
67
+ include_chart_images: bool = True,
68
+ export_html: bool = False,
69
+ ) -> list[Path]:
70
+ """
71
+ Export analysis results to Excel with each component on a separate tab.
72
+
73
+ Creates a multi-sheet Excel workbook with organized analysis results:
74
+ - Summary: Analysis metadata, SDS info, signal counts
75
+ - Charts: One tab per chart (Xbar, S, stratified X, etc.)
76
+ - Residuals: R1-R5 variance decomposition (if available)
77
+ - Effects: Main effects (if calculated)
78
+ - Interactions: Interaction terms (if calculated)
79
+ - Full_Dataset: Complete analysis dataset (optional)
80
+
81
+ Parameters
82
+ ----------
83
+ filepath : str
84
+ Output Excel file path (e.g., 'analysis.xlsx')
85
+ include_summary : bool, default=True
86
+ Include summary tab with analysis metadata
87
+ include_charts : bool, default=True
88
+ Include tabs for each chart
89
+ include_residuals : bool, default=True
90
+ Include residuals tab if available
91
+ include_effects : bool, default=True
92
+ Include effects tab if available
93
+ include_interactions : bool, default=True
94
+ Include interactions tab if available
95
+ include_full_dataset : bool, default=False
96
+ Include complete analysis dataset (can be large)
97
+ format_cells : bool, default=True
98
+ Apply formatting (bold headers, auto-width, freeze panes)
99
+ include_chart_images : bool, default=True
100
+ Include visual charts tab with embedded chart images (requires plotly)
101
+ export_html : bool, default=False
102
+ Also write companion interactive HTML files next to the workbook.
103
+ Off by default: these are full standalone plotly documents and run
104
+ to several megabytes, which is a surprising thing to receive from a
105
+ call that named one ``.xlsx`` file.
106
+
107
+ Examples
108
+ --------
109
+ Export with default settings (all available data except full dataset):
110
+
111
+ >>> exporter.export('my_analysis.xlsx')
112
+
113
+ Export with full dataset included:
114
+
115
+ >>> exporter.export('complete_report.xlsx', include_full_dataset=True)
116
+
117
+ Export only charts and summary (minimal):
118
+
119
+ >>> exporter.export('charts_only.xlsx',
120
+ ... include_residuals=False,
121
+ ... include_effects=False,
122
+ ... include_interactions=False)
123
+
124
+ Notes
125
+ -----
126
+ - Tab names are limited to 31 characters (Excel limitation)
127
+ - Chart tabs are prefixed with 'Chart_' for clarity
128
+ - Stratified charts use format 'X_{group_name}'
129
+ - Summary tab includes SDS info and signal counts
130
+ - Formatting includes frozen headers and auto-sized columns
131
+ - Visual_Charts tab includes embedded images (requires Chrome for kaleido)
132
+ - ``export_html=True`` additionally writes companion interactive HTML
133
+ files next to the workbook, and every written path is returned
134
+
135
+ Returns
136
+ -------
137
+ list[Path]
138
+ Every file written: the workbook, plus any companion HTML. Nothing
139
+ is written that is not in this list.
140
+ """
141
+ # Companion HTML first: the Visual_Charts tab points the reader at these
142
+ # files by name, so they have to exist (or not) before the tab is written.
143
+ self._html_exported = self._export_html_charts(filepath) if export_html else []
144
+
145
+ # Create Excel writer
146
+ with pd.ExcelWriter(filepath, engine='openpyxl') as writer:
147
+ # 1. Summary Tab
148
+ if include_summary:
149
+ self._write_summary_tab(writer, format_cells)
150
+
151
+ # 2. Chart Tabs
152
+ if include_charts:
153
+ self._write_chart_tabs(writer, format_cells)
154
+
155
+ # 3. Residuals Tab
156
+ if include_residuals and self.result.has_residuals:
157
+ self._write_residuals_tab(writer, format_cells)
158
+
159
+ # 4. Effects Tab
160
+ if include_effects and self.result.has_effects:
161
+ self._write_effects_tab(writer, format_cells)
162
+
163
+ # 5. Interactions Tab
164
+ if include_interactions and self.result.has_interactions:
165
+ self._write_interactions_tab(writer, format_cells)
166
+
167
+ # 6. Full Dataset Tab (optional - can be large)
168
+ if include_full_dataset:
169
+ self._write_full_dataset_tab(writer, format_cells)
170
+
171
+ # 7. Visual Charts Tab (with embedded images)
172
+ if include_chart_images:
173
+ self._write_visual_charts_tab(writer, filepath)
174
+
175
+ written = [Path(filepath), *self._html_exported]
176
+ logger.info(f'Analysis results exported to: {filepath}')
177
+ return written
178
+
179
+ def _write_summary_tab(self, writer: pd.ExcelWriter, format_cells: bool) -> None:
180
+ """Write summary tab with analysis metadata."""
181
+ # Convert summary dict to DataFrame for better Excel formatting
182
+ summary_items = []
183
+
184
+ # SDS Information
185
+ summary_items.append(('Category', 'Sampling Design State'))
186
+ summary_items.append(('ADS', self.result.summary['analytical_sds']))
187
+ summary_items.append(('Description', self.result.summary['analytical_sds_description']))
188
+ summary_items.append(('Replication Type', self.result.summary['replication_type']))
189
+ summary_items.append(('', ''))
190
+
191
+ # Analysis Configuration
192
+ summary_items.append(('Category', 'Analysis Configuration'))
193
+ summary_items.append(('Analysis Type', self.result.summary['analysis_type']))
194
+ summary_items.append(('Response Variable', self.result.summary['response_var']))
195
+ summary_items.append(('Grouping Variables', str(self.result.summary['grouping_vars'])))
196
+ summary_items.append(('Time Variable', self.result.summary['time_var']))
197
+ summary_items.append(('', ''))
198
+
199
+ # Data Dimensions
200
+ summary_items.append(('Category', 'Data Dimensions'))
201
+ summary_items.append(('Total Observations', self.result.summary['n_observations']))
202
+ summary_items.append(('Number of Charts', self.result.summary['n_charts']))
203
+ summary_items.append(('Chart Types', ', '.join(self.result.summary['chart_types'])))
204
+ summary_items.append(('Is Stratified', self.result.summary['is_stratified']))
205
+ summary_items.append(('', ''))
206
+
207
+ # Capabilities
208
+ summary_items.append(('Category', 'Analysis Capabilities'))
209
+ summary_items.append(('Has Residuals', self.result.summary['has_residuals']))
210
+ summary_items.append(('Has Effects', self.result.summary['has_effects']))
211
+ summary_items.append(('Has Interactions', self.result.summary['has_interactions']))
212
+ summary_items.append(('Variance Decomposition', self.result.summary['variance_decomposition']))
213
+ summary_items.append(('Interaction Analysis', self.result.summary['interaction_analysis']))
214
+ summary_items.append(('', ''))
215
+
216
+ # Signals
217
+ summary_items.append(('Category', 'Process Signals'))
218
+ summary_items.append(('Total Signals Detected', self.result.summary['n_signals_total']))
219
+
220
+ # Add ADS capabilities as a list
221
+ if self.result.summary['analytical_sds_capabilities']:
222
+ summary_items.append(('', ''))
223
+ summary_items.append(('Category', 'ADS Capabilities'))
224
+ for capability in self.result.summary['analytical_sds_capabilities']:
225
+ summary_items.append(('', capability))
226
+
227
+ # Create DataFrame
228
+ summary_df = pd.DataFrame(summary_items, columns=['Attribute', 'Value'])
229
+
230
+ # Write to Excel
231
+ summary_df.to_excel(writer, sheet_name='Summary', index=False)
232
+
233
+ # Apply formatting if requested
234
+ if format_cells:
235
+ ws = writer.sheets['Summary']
236
+ self._apply_formatting(ws)
237
+
238
+ def _write_chart_tabs(self, writer: pd.ExcelWriter, format_cells: bool) -> None:
239
+ """Write tabs for each chart."""
240
+
241
+ # Check if this is a stratified analysis (X/mR with grouping_vars)
242
+ if self.result.summary['is_stratified']:
243
+ # Stratified analysis: combine all stratified charts into single tab
244
+ self._write_stratified_chart_tab(writer, format_cells)
245
+
246
+ # For stratified X/mR charts, also create a summary tab for quick comparison
247
+ chart_type = self.result._analysis_type
248
+ if chart_type in ['X', 'mR']:
249
+ self._write_stratified_summary_tab(writer, format_cells)
250
+ else:
251
+ # Standard analysis: each chart gets its own tab
252
+ for chart_name, chart_info in self.result.charts.items():
253
+ # Get chart data
254
+ chart_data = chart_info.get('data')
255
+ if chart_data is None or not isinstance(chart_data, pd.DataFrame):
256
+ continue
257
+
258
+ # Create tab name (Excel limit: 31 chars)
259
+ tab_name = f'Chart_{chart_name}'
260
+ if len(tab_name) > 31:
261
+ tab_name = tab_name[:31]
262
+
263
+ # Write chart data
264
+ chart_data.to_excel(writer, sheet_name=tab_name, index=False)
265
+
266
+ # Apply formatting if requested
267
+ if format_cells:
268
+ ws = writer.sheets[tab_name]
269
+ self._apply_formatting(ws)
270
+
271
+ def _write_stratified_chart_tab(self, writer: pd.ExcelWriter, format_cells: bool) -> None:
272
+ """
273
+ Write all stratified charts to a single combined tab.
274
+
275
+ Combines all strata into one worksheet with a column identifying the stratum,
276
+ making it easy to compare and filter in Excel.
277
+ """
278
+ # Collect all stratified charts
279
+ combined_data = []
280
+
281
+ for chart_name, chart_info in self.result.charts.items():
282
+ # Skip standard charts (will be written separately)
283
+ if chart_name in STANDARD_CHART_NAMES:
284
+ continue
285
+
286
+ chart_data = chart_info.get('data')
287
+ if chart_data is None or not isinstance(chart_data, pd.DataFrame):
288
+ continue
289
+
290
+ # For X/mR with grouping_vars, the 'rsg' column already exists
291
+ # and contains the stratification identifier (chart name)
292
+ # No need to add it - just use the data as-is
293
+ combined_data.append(chart_data.copy())
294
+
295
+ # Combine all stratified charts
296
+ if combined_data:
297
+ combined_df = pd.concat(combined_data, ignore_index=True)
298
+
299
+ # Create descriptive tab name
300
+ chart_type = self.result._analysis_type
301
+ grouping_vars = self.result._ads.spec.rsg_vars if self.result._ads.spec.has_grouping else []
302
+
303
+ if len(grouping_vars) == 1:
304
+ tab_name = f'Chart_{chart_type}_by_{grouping_vars[0]}'
305
+ else:
306
+ tab_name = f'Chart_{chart_type}_Stratified'
307
+
308
+ # Excel tab name limit: 31 chars
309
+ if len(tab_name) > 31:
310
+ tab_name = tab_name[:31]
311
+
312
+ # Write combined data
313
+ combined_df.to_excel(writer, sheet_name=tab_name, index=False)
314
+
315
+ # Apply formatting if requested
316
+ if format_cells:
317
+ ws = writer.sheets[tab_name]
318
+ self._apply_formatting(ws)
319
+
320
+ # Also write any standard charts (Xbar, S, etc.)
321
+ # These are separate analyses, not stratified
322
+ for chart_name in STANDARD_CHART_NAMES:
323
+ if chart_name in self.result.charts:
324
+ chart_info = self.result.charts[chart_name]
325
+ chart_data = chart_info.get('data')
326
+ if chart_data is not None and isinstance(chart_data, pd.DataFrame):
327
+ tab_name = f'Chart_{chart_name}'
328
+ chart_data.to_excel(writer, sheet_name=tab_name, index=False)
329
+
330
+ if format_cells:
331
+ ws = writer.sheets[tab_name]
332
+ self._apply_formatting(ws)
333
+
334
+ def _write_stratified_summary_tab(self, writer: pd.ExcelWriter, format_cells: bool) -> None:
335
+ """
336
+ Create a summary tab for stratified X/mR charts.
337
+
338
+ Provides a high-level comparison across all strata showing:
339
+ - Stratum identifier (RSG)
340
+ - Number of observations per stratum
341
+ - Mean, LPL, UPL for each stratum
342
+ - Total signals (beyond_limits != 0)
343
+ - Signal rate (% of observations with signals)
344
+ """
345
+ # Collect data from all stratified charts
346
+ all_chart_data = []
347
+
348
+ for chart_name, chart_info in self.result.charts.items():
349
+ # Skip standard charts
350
+ if chart_name in STANDARD_CHART_NAMES:
351
+ continue
352
+
353
+ chart_data = chart_info.get('data')
354
+ if chart_data is not None and isinstance(chart_data, pd.DataFrame):
355
+ all_chart_data.append(chart_data)
356
+
357
+ # Combine all chart data
358
+ if not all_chart_data:
359
+ return
360
+
361
+ combined_data = pd.concat(all_chart_data, ignore_index=True)
362
+
363
+ # Group by stratum (rsg column) and calculate summary statistics
364
+ if 'rsg' not in combined_data.columns:
365
+ return
366
+
367
+ summary_rows = []
368
+
369
+ for stratum, group in combined_data.groupby('rsg', sort=False, observed=True):
370
+ n_obs = len(group)
371
+
372
+ # Get mean, lpl, upl (should be constant within stratum)
373
+ mean_val = group['mean'].iloc[0] if 'mean' in group.columns else None
374
+ lpl_val = group['lpl'].iloc[0] if 'lpl' in group.columns else None
375
+ upl_val = group['upl'].iloc[0] if 'upl' in group.columns else None
376
+
377
+ # Count signals
378
+ if 'beyond_limits' in group.columns:
379
+ n_signals = (group['beyond_limits'] != 0).sum()
380
+ signal_rate = (n_signals / n_obs * 100) if n_obs > 0 else 0
381
+ else:
382
+ n_signals = None
383
+ signal_rate = None
384
+
385
+ summary_rows.append(
386
+ {
387
+ 'Stratum': stratum,
388
+ 'Observations': n_obs,
389
+ 'Mean': mean_val,
390
+ 'LPL': lpl_val,
391
+ 'UPL': upl_val,
392
+ 'Signals': n_signals,
393
+ 'Signal_Rate_%': signal_rate,
394
+ }
395
+ )
396
+
397
+ if summary_rows:
398
+ # Create summary DataFrame
399
+ summary_df = pd.DataFrame(summary_rows)
400
+
401
+ # Sort by signal count (descending) so worst strata appear first
402
+ if 'Signals' in summary_df.columns:
403
+ summary_df = summary_df.sort_values('Signals', ascending=False)
404
+
405
+ # Write to Excel
406
+ tab_name = 'Stratified_Summary'
407
+ summary_df.to_excel(writer, sheet_name=tab_name, index=False)
408
+
409
+ if format_cells:
410
+ ws = writer.sheets[tab_name]
411
+ self._apply_formatting(ws)
412
+
413
+ def _write_residuals_tab(self, writer: pd.ExcelWriter, format_cells: bool) -> None:
414
+ """Write residuals tab with original data columns."""
415
+ if self.result.residuals is not None:
416
+ # Include original data columns with residuals for context
417
+ # Get original input columns from summary
418
+ time_var = self.result.summary.get('time_var')
419
+ grouping_vars = self.result.summary.get('grouping_vars', [])
420
+ response_var = self.result.summary.get('response_var')
421
+
422
+ # Build list of columns to include: original inputs + residuals
423
+ original_cols = []
424
+ if time_var and time_var in self.result.dataset.columns:
425
+ original_cols.append(time_var)
426
+ if grouping_vars:
427
+ original_cols.extend([g for g in grouping_vars if g in self.result.dataset.columns])
428
+ if response_var and response_var in self.result.dataset.columns:
429
+ original_cols.append(response_var)
430
+
431
+ # Add residual columns
432
+ residual_cols = ['R1', 'R2', 'R3', 'R4', 'R5']
433
+ all_cols = original_cols + residual_cols
434
+
435
+ # Extract these columns from dataset (which has both)
436
+ residuals_with_data = self.result.dataset[all_cols].copy()
437
+
438
+ # Write to Excel
439
+ residuals_with_data.to_excel(writer, sheet_name='Residuals', index=False)
440
+
441
+ if format_cells:
442
+ ws = writer.sheets['Residuals']
443
+ self._apply_formatting(ws)
444
+
445
+ def _write_effects_tab(self, writer: pd.ExcelWriter, format_cells: bool) -> None:
446
+ """Write effects tab."""
447
+ # Convert effects dict to DataFrame
448
+ effects_data = []
449
+
450
+ for effect_name, effect_values in self.result.effects.items():
451
+ if isinstance(effect_values, pd.DataFrame):
452
+ # For DataFrames, extract all rows
453
+ for _idx, row in effect_values.iterrows():
454
+ # Get the value column - look for specific patterns
455
+ # Exclude first column (grouping variable) and match effect/ME columns
456
+ value_col = [
457
+ c
458
+ for c in effect_values.columns[1:]
459
+ if 'effect' in c.lower() or c.upper().endswith('_ME') or c.upper() == 'PT_ME'
460
+ ]
461
+ # Use first match or last column as value
462
+ val = row[value_col[0]] if value_col else row.iloc[-1]
463
+
464
+ # Get the level/category
465
+ level_col = effect_values.columns[0]
466
+ level = row[level_col]
467
+
468
+ effects_data.append({'Effect_Type': effect_name, 'Level': str(level), 'Value': val})
469
+ elif isinstance(effect_values, pd.Series):
470
+ for idx, val in effect_values.items():
471
+ effects_data.append({'Effect_Type': effect_name, 'Level': str(idx), 'Value': val})
472
+ elif isinstance(effect_values, (int, float)):
473
+ effects_data.append({'Effect_Type': effect_name, 'Level': '', 'Value': effect_values})
474
+
475
+ if effects_data:
476
+ effects_df = pd.DataFrame(effects_data)
477
+ effects_df.to_excel(writer, sheet_name='Effects', index=False)
478
+
479
+ if format_cells:
480
+ ws = writer.sheets['Effects']
481
+ self._apply_formatting(ws)
482
+
483
+ def _write_interactions_tab(self, writer: pd.ExcelWriter, format_cells: bool) -> None:
484
+ """Write interactions tab."""
485
+ # Convert interactions dict to DataFrame
486
+ interactions_data = []
487
+
488
+ for interaction_name, interaction_values in self.result.interactions.items():
489
+ if isinstance(interaction_values, pd.Series):
490
+ # Handle MultiIndex Series (cell-level interactions)
491
+ if isinstance(interaction_values.index, pd.MultiIndex):
492
+ # Extract index level names and values
493
+ for idx_tuple, val in interaction_values.items():
494
+ # Build a dict with index level values plus the interaction value
495
+ row_dict = {'Interaction': interaction_name}
496
+
497
+ # Add each index level as a separate column
498
+ for level_name, level_val in zip(interaction_values.index.names, idx_tuple, strict=False):
499
+ row_dict[level_name] = level_val
500
+
501
+ row_dict['Value'] = val
502
+ interactions_data.append(row_dict)
503
+ else:
504
+ # Regular index - use as Combination
505
+ for idx, val in interaction_values.items():
506
+ interactions_data.append(
507
+ {'Interaction': interaction_name, 'Combination': str(idx), 'Value': val}
508
+ )
509
+ elif isinstance(interaction_values, pd.DataFrame):
510
+ # For DataFrames, flatten them
511
+ for row_idx, row in interaction_values.iterrows():
512
+ for col_name, val in row.items():
513
+ interactions_data.append(
514
+ {'Interaction': interaction_name, 'Combination': f'{row_idx} x {col_name}', 'Value': val}
515
+ )
516
+
517
+ if interactions_data:
518
+ interactions_df = pd.DataFrame(interactions_data)
519
+ interactions_df.to_excel(writer, sheet_name='Interactions', index=False)
520
+
521
+ if format_cells:
522
+ ws = writer.sheets['Interactions']
523
+ self._apply_formatting(ws)
524
+
525
+ def _write_full_dataset_tab(self, writer: pd.ExcelWriter, format_cells: bool) -> None:
526
+ """Write full dataset tab."""
527
+ self.result.dataset.to_excel(writer, sheet_name='Full_Dataset', index=False)
528
+
529
+ if format_cells:
530
+ ws = writer.sheets['Full_Dataset']
531
+ self._apply_formatting(ws)
532
+
533
+ def _write_visual_charts_tab(self, writer: pd.ExcelWriter, filepath: str) -> None:
534
+ """
535
+ Write visual charts tab with embedded chart images.
536
+
537
+ Creates a 'Visual_Charts' worksheet with:
538
+ - Embedded PNG images of control charts
539
+ - Hyperlinks to interactive HTML files
540
+ - Organized layout for presentation
541
+ """
542
+ try:
543
+ from io import BytesIO
544
+
545
+ from openpyxl.drawing.image import Image
546
+ from openpyxl.styles import Font
547
+
548
+ from ..plotting import Plotter
549
+
550
+ # Get workbook to add images
551
+ wb = writer.book
552
+ ws = wb.create_sheet('Visual_Charts')
553
+
554
+ # Create plotter
555
+ plotter = Plotter(self.result)
556
+
557
+ # Track row position for layout
558
+ current_row = 1
559
+
560
+ # Export combined charts first (Xbar, S, etc.)
561
+ combined_charts = [name for name in self.result.charts if name in STANDARD_CHART_NAMES]
562
+
563
+ if combined_charts:
564
+ ws[f'A{current_row}'] = 'COMBINED CONTROL CHARTS'
565
+ ws[f'A{current_row}'].font = Font(bold=True, size=14)
566
+ current_row += 2
567
+
568
+ for chart_name in combined_charts:
569
+ try:
570
+ # Generate chart
571
+ fig = plotter.plot(chart=chart_name, width=1200, height=500, theme='processbehavior')
572
+
573
+ # Save as image
574
+ img_buffer = BytesIO()
575
+ fig.figure.write_image(img_buffer, format='png', width=1200, height=500)
576
+ img_buffer.seek(0)
577
+
578
+ # Add title
579
+ ws[f'A{current_row}'] = f'{chart_name} Chart'
580
+ ws[f'A{current_row}'].font = Font(bold=True)
581
+ current_row += 1
582
+
583
+ # Add image
584
+ img = Image(img_buffer)
585
+ img.width = 800 # Scale to fit Excel
586
+ img.height = 333
587
+ ws.add_image(img, f'A{current_row}')
588
+ current_row += 18 # Space for image + margin
589
+
590
+ logger.info(f'Added {chart_name} chart image to Excel')
591
+
592
+ except ImportError as e:
593
+ # Static export is unavailable for every chart, not just
594
+ # this one — say so once instead of repeating it per chart.
595
+ logger.warning(f'Static chart images unavailable: {e}')
596
+ ws[f'A{current_row}'] = (
597
+ 'Static images unavailable — install with: '
598
+ "pip install 'processbehavior[images]'"
599
+ )
600
+ current_row += 2
601
+ break
602
+ except Exception as e:
603
+ logger.warning(f'Could not add {chart_name} chart image: {e}')
604
+ ws[f'A{current_row}'] = f'Could not render {chart_name} chart: {e}'
605
+ current_row += 2
606
+
607
+ # Note the companion HTML files — but only when they were asked for.
608
+ # This block used to claim them unconditionally, so a default export
609
+ # (which writes no HTML) shipped a workbook telling the reader to
610
+ # open files that were never created.
611
+ if self._html_exported:
612
+ current_row += 2
613
+ ws[f'A{current_row}'] = 'INTERACTIVE CHARTS'
614
+ ws[f'A{current_row}'].font = Font(bold=True, size=14)
615
+ current_row += 1
616
+ names = ', '.join(path.name for path in self._html_exported)
617
+ ws[f'A{current_row}'] = f'Interactive HTML exported alongside this workbook: {names}'
618
+ current_row += 1
619
+ ws[f'A{current_row}'] = (
620
+ 'Open them in a web browser for full interactivity (zoom, pan, hover tooltips).'
621
+ )
622
+
623
+ except ImportError as e:
624
+ logger.warning(f'Could not create visual charts tab: {e}')
625
+ logger.warning('Install kaleido for image export: pip install kaleido')
626
+
627
+ def _export_html_charts(self, filepath: str) -> list[Path]:
628
+ """
629
+ Export interactive HTML charts alongside the Excel file.
630
+
631
+ Creates HTML files in the same directory as the Excel file:
632
+ - {basename}_combined.html - Combined Xbar/S charts
633
+ - {basename}_stratified.html - Stratified X charts (if applicable)
634
+ """
635
+ written: list[Path] = []
636
+ try:
637
+ from ..plotting import Plotter
638
+
639
+ output_path = Path(filepath)
640
+ output_dir = output_path.parent
641
+ base_name = output_path.stem
642
+
643
+ plotter = Plotter(self.result)
644
+
645
+ # Export combined charts
646
+ combined_charts = [name for name in self.result.charts if name in STANDARD_CHART_NAMES]
647
+
648
+ if combined_charts:
649
+ html_file = output_dir / f'{base_name}_combined.html'
650
+ fig = plotter.plot(
651
+ width=1400, height=800, theme='processbehavior', title=f'Control Charts: {base_name}'
652
+ )
653
+ fig.save_html(str(html_file))
654
+ written.append(html_file)
655
+ logger.info(f'Exported interactive combined charts to: {html_file}')
656
+
657
+ # Export stratified charts if present
658
+ stratified_charts = [
659
+ name
660
+ for name in self.result.charts
661
+ if name not in combined_charts and len(self.result.charts) > len(combined_charts)
662
+ ]
663
+
664
+ if stratified_charts and self.result.summary.get('is_stratified', False):
665
+ html_file = output_dir / f'{base_name}_stratified.html'
666
+ # This will plot all stratified charts
667
+ fig = plotter.plot(
668
+ width=1800, height=1200, theme='processbehavior', title=f'Stratified Control Charts: {base_name}'
669
+ )
670
+ fig.save_html(str(html_file))
671
+ written.append(html_file)
672
+ logger.info(f'Exported interactive stratified charts to: {html_file}')
673
+
674
+ except Exception as e:
675
+ logger.warning(f'Could not export HTML charts: {e}')
676
+ return written
677
+
678
+ def _apply_formatting(self, worksheet) -> None:
679
+ """
680
+ Apply standard formatting to worksheet.
681
+
682
+ - Bold headers
683
+ - Freeze top row
684
+ - Auto-size columns
685
+ """
686
+ from openpyxl.styles import Font
687
+
688
+ # Bold headers (first row)
689
+ for cell in worksheet[1]:
690
+ cell.font = Font(bold=True)
691
+
692
+ # Freeze top row
693
+ worksheet.freeze_panes = 'A2'
694
+
695
+ # Auto-size columns
696
+ for column in worksheet.columns:
697
+ max_length = 0
698
+ column_letter = column[0].column_letter
699
+
700
+ for cell in column:
701
+ try:
702
+ if cell.value:
703
+ max_length = max(max_length, len(str(cell.value)))
704
+ except Exception: # noqa: S110
705
+ pass
706
+
707
+ adjusted_width = min(max_length + 2, 50) # Cap at 50
708
+ worksheet.column_dimensions[column_letter].width = adjusted_width