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