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,1469 @@
1
+ """Main plotting coordinator for ProcessBehavior analysis results.
2
+
3
+ Orchestrates chart creation by delegating to specialised renderers.
4
+ This module is the routing/coordination layer — actual rendering lives
5
+ in ``renderers.py``, ``residuals.py``, ``report.py``, and
6
+ ``effects_charts.py``.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import logging
12
+ from typing import TYPE_CHECKING, Any
13
+
14
+ import numpy as np
15
+ import pandas as pd
16
+ import plotly.graph_objects as go
17
+ from plotly.subplots import make_subplots
18
+
19
+ from ..data_preparation import encode_rsg, encode_rsg_series
20
+ from ..exceptions import ChartNotAvailableError, ValidationError
21
+ from ..spc_constants import RESIDUAL_LABELS, normalize_chart_name
22
+ from ..types import ChartPayload
23
+ from .contracts import PlotError, build_render_context, build_render_spec
24
+ from .control_chart import ControlChartFigure
25
+ from .renderers import render_control_chart, render_histogram
26
+ from .themes import ChartTheme, apply_theme, get_theme
27
+ from .x_axis_layout import (
28
+ StratifiedBoundaries,
29
+ apply_x_axis_layout,
30
+ compute_x_axis_layout,
31
+ parse_lane_boundaries,
32
+ )
33
+
34
+ if TYPE_CHECKING:
35
+ from processbehavior.analysis_result import AnalysisResult
36
+
37
+ logger = logging.getLogger(__name__)
38
+
39
+ _EFFECTS_CHART_TYPES = frozenset(
40
+ {
41
+ 'Effects',
42
+ 'MainEffects',
43
+ 'TimeEffects',
44
+ 'TimeInteraction',
45
+ 'FactorInteraction',
46
+ }
47
+ )
48
+
49
+ # Readable names for the VAS residual codes, used by _generate_title. A missing key
50
+ # falls back to the code itself and renders as "R6 (R6)", so every residual the library
51
+ # can chart must appear here — see tests/test_residual_labels.py.
52
+ #
53
+ # Re-exported, not redefined: this used to be a second copy, and a third in
54
+ # spc_constants.RESIDUAL_ALIASES drifted far enough to label R5 "Noise". One table now.
55
+ _RESIDUAL_LABELS = RESIDUAL_LABELS
56
+
57
+
58
+ class Plotter:
59
+ """Unified plotting interface for ProcessBehavior analysis results.
60
+
61
+ Coordinates chart creation with built-in support for single and
62
+ faceted control charts, residual diagnostics, effects visualisation,
63
+ and HTML report generation.
64
+
65
+ Examples
66
+ --------
67
+ Simple plotting::
68
+
69
+ plotter = Plotter(result)
70
+ fig = plotter.plot()
71
+ fig.show()
72
+
73
+ Faceted charts::
74
+
75
+ fig = plotter.plot(facet=True)
76
+
77
+ Customize appearance::
78
+
79
+ fig = plotter.plot(chart='Xbar', theme='minimal',
80
+ highlight_signals=True)
81
+ """
82
+
83
+ def __init__(self, analysis_result: AnalysisResult):
84
+ self.result = analysis_result
85
+ self.charts = analysis_result.charts
86
+ self.summary = analysis_result.summary
87
+ self._theme: ChartTheme | None = None
88
+
89
+ # -----------------------------------------------------------------
90
+ # Main entry point
91
+ # -----------------------------------------------------------------
92
+
93
+ def plot( # noqa: C901
94
+ self,
95
+ chart: str | None = None,
96
+ facet: bool = False,
97
+ ncols: int = 2,
98
+ highlight_signals: bool = True,
99
+ show_limits: bool = True,
100
+ show_limit_values: bool = True,
101
+ show_zones: bool = False,
102
+ show_rules: bool = False,
103
+ show_stats: bool = False,
104
+ theme: str | ChartTheme = 'processbehavior',
105
+ width: int = 1000,
106
+ height: int | None = None,
107
+ aspect_ratio: float | None = None,
108
+ title: str | None = None,
109
+ xaxis_title: str | None = None,
110
+ yaxis_title: str | None = None,
111
+ shared_yaxis: bool = True,
112
+ yaxis_padding: float = 0.05,
113
+ vertical_spacing: float = 0.15,
114
+ ) -> ControlChartFigure:
115
+ """Create control chart visualisation.
116
+
117
+ Parameters
118
+ ----------
119
+ chart : str, optional
120
+ Specific chart to plot ('Xbar', 'S', 'X', etc.).
121
+ Also accepts effects chart types: 'Effects', 'MainEffects',
122
+ 'TimeEffects', 'TimeInteraction', 'FactorInteraction'.
123
+ If None, plots all available charts.
124
+ facet : bool, default False
125
+ Whether to create faceted plot for stratified data.
126
+ ncols : int, default 2
127
+ Number of columns in faceted layout.
128
+ highlight_signals : bool, default True
129
+ Highlight points beyond control limits.
130
+ show_limits : bool, default True
131
+ Show control limit lines.
132
+ show_limit_values : bool, default True
133
+ Show numeric values in limit labels.
134
+ show_zones : bool, default False
135
+ Show zone shading (A/B/C zones).
136
+ show_rules : bool, default False
137
+ Show additional run rules (Western Electric Rules 2-8).
138
+ show_stats : bool, default False
139
+ Show statistics box.
140
+ theme : str or ChartTheme, default 'processbehavior'
141
+ Visual theme.
142
+ width : int, default 1000
143
+ Figure width in pixels.
144
+ height : int, optional
145
+ Figure height in pixels (auto-calculated if None).
146
+ aspect_ratio : float, optional
147
+ Width-to-height ratio (overrides height if specified).
148
+ title : str, optional
149
+ Custom title.
150
+ xaxis_title, yaxis_title : str, optional
151
+ Custom axis labels.
152
+ shared_yaxis : bool, default True
153
+ Whether faceted charts share y-axis range.
154
+ yaxis_padding : float, default 0.05
155
+ Padding fraction for y-axis range.
156
+ vertical_spacing : float, default 0.15
157
+ Vertical spacing between rows in faceted layouts.
158
+
159
+ Returns
160
+ -------
161
+ ControlChartFigure
162
+ Interactive figure.
163
+ """
164
+ # Resolve theme
165
+ if isinstance(theme, str):
166
+ self._theme = get_theme(theme)
167
+ else:
168
+ self._theme = theme
169
+
170
+ # Normalize chart name
171
+ if chart:
172
+ chart = normalize_chart_name(chart)
173
+
174
+ # Handle effects charts
175
+ if chart in _EFFECTS_CHART_TYPES:
176
+ return self._plot_effects_chart(
177
+ chart_type=chart,
178
+ width=width,
179
+ height=height,
180
+ title=title,
181
+ )
182
+
183
+ # Validate
184
+ if chart and chart not in self.charts:
185
+ available = list(self.charts.keys())
186
+ raise ChartNotAvailableError(
187
+ f"Chart '{chart}' not found.\n"
188
+ f'Available charts: {available}\n'
189
+ f'Hint: Use list_charts() to see all options',
190
+ chart=chart,
191
+ available=available,
192
+ )
193
+
194
+ # Determine what to plot
195
+ charts_to_plot = self._resolve_charts(chart, facet)
196
+
197
+ if not charts_to_plot:
198
+ raise PlotError('No charts available to plot.\nHint: Check result.charts to see available charts')
199
+
200
+ # Reorder stratified companion pairs for side-by-side layout
201
+ charts_to_plot, forced_ncols = self._reorder_companion_pairs(charts_to_plot)
202
+ if forced_ncols is not None:
203
+ ncols = forced_ncols
204
+
205
+ # Calculate height
206
+ height = self._resolve_height(
207
+ height,
208
+ width,
209
+ aspect_ratio,
210
+ len(charts_to_plot),
211
+ ncols,
212
+ )
213
+
214
+ # Build display options dict for reuse
215
+ display_opts = dict(
216
+ highlight_signals=highlight_signals,
217
+ show_limits=show_limits,
218
+ show_limit_values=show_limit_values,
219
+ show_zones=show_zones,
220
+ show_rules=show_rules,
221
+ show_stats=show_stats,
222
+ )
223
+
224
+ # Create figure
225
+ if len(charts_to_plot) == 1:
226
+ fig = self._plot_single_chart(
227
+ list(charts_to_plot.values())[0],
228
+ list(charts_to_plot.keys())[0],
229
+ width=width,
230
+ height=height,
231
+ xaxis_title=xaxis_title,
232
+ yaxis_title=yaxis_title,
233
+ **display_opts,
234
+ )
235
+ else:
236
+ effective_shared = shared_yaxis
237
+ per_type_shared = False
238
+ if shared_yaxis:
239
+ base_types = {self._get_base_chart_type(n) for n in charts_to_plot}
240
+ if len(base_types) > 1:
241
+ effective_shared = False
242
+ per_type_shared = True
243
+
244
+ fig = self._plot_faceted(
245
+ charts_to_plot,
246
+ ncols=ncols,
247
+ width=width,
248
+ height=height,
249
+ xaxis_title=xaxis_title,
250
+ yaxis_title=yaxis_title,
251
+ shared_yaxis=effective_shared,
252
+ yaxis_padding=yaxis_padding,
253
+ vertical_spacing=vertical_spacing,
254
+ per_type_shared=per_type_shared,
255
+ **display_opts,
256
+ )
257
+
258
+ # Apply theme
259
+ fig = apply_theme(fig, self._theme)
260
+
261
+ # Title
262
+ if title:
263
+ fig.update_layout(title=title)
264
+ elif len(charts_to_plot) == 1:
265
+ chart_name = list(charts_to_plot.keys())[0]
266
+ chart_info = list(charts_to_plot.values())[0]
267
+ fig.update_layout(title=self._generate_title(chart_name, chart_info))
268
+
269
+ return ControlChartFigure(fig, self.result)
270
+
271
+ # -----------------------------------------------------------------
272
+ # Single chart
273
+ # -----------------------------------------------------------------
274
+
275
+ def _plot_single_chart(
276
+ self,
277
+ chart_info: ChartPayload,
278
+ chart_name: str,
279
+ highlight_signals: bool,
280
+ show_limits: bool,
281
+ show_limit_values: bool,
282
+ show_zones: bool,
283
+ show_rules: bool,
284
+ show_stats: bool,
285
+ width: int,
286
+ height: int,
287
+ xaxis_title: str | None = None,
288
+ yaxis_title: str | None = None,
289
+ ) -> go.Figure:
290
+ """Create a single control chart."""
291
+ metadata = chart_info.get('metadata', {})
292
+
293
+ # Histogram delegation
294
+ if metadata.get('chart_type') == 'Histogram':
295
+ return self._plot_histogram(
296
+ chart_info,
297
+ chart_name,
298
+ show_stats=show_stats,
299
+ width=width,
300
+ height=height,
301
+ xaxis_title=xaxis_title,
302
+ yaxis_title=yaxis_title,
303
+ )
304
+
305
+ fig = go.Figure()
306
+
307
+ # Build render context
308
+ value_col = self._get_value_column(chart_info, chart_name)
309
+ x_col = self._get_x_column(chart_info['data'])
310
+ center_key = self._get_center_key(chart_info['statistics'])
311
+
312
+ spec = build_render_spec(chart_info, chart_name, x_col, center_key)
313
+ ctx = build_render_context(
314
+ spec,
315
+ chart_info,
316
+ chart_name,
317
+ self._theme,
318
+ self.result,
319
+ highlight_signals=highlight_signals,
320
+ show_limits=show_limits,
321
+ show_limit_values=show_limit_values,
322
+ show_zones=show_zones,
323
+ show_rules=show_rules,
324
+ show_stats=show_stats,
325
+ is_faceted=False,
326
+ )
327
+
328
+ # Render
329
+ render_control_chart(fig, ctx)
330
+
331
+ # X-axis layout: ticks + (optional) cell band + title + bottom margin.
332
+ # Boundaries from focused or stratified callers must already be flat —
333
+ # raise on dict input rather than silently use the wrong stratum.
334
+ x_label = xaxis_title or self._get_xaxis_label(x_col, chart_name)
335
+ y_label = yaxis_title or self._get_yaxis_label(value_col, metadata.get('chart_type'))
336
+
337
+ boundaries = parse_lane_boundaries(metadata.get('lane_boundaries'))
338
+ if isinstance(boundaries, StratifiedBoundaries):
339
+ raise PlotError(
340
+ 'Stratified lane_boundaries leaked into a single-chart render. '
341
+ 'Caller (focus() or _get_stratified_charts) must unpack per-stratum '
342
+ 'boundaries before delegating to the plotter.'
343
+ )
344
+ layout = compute_x_axis_layout(
345
+ data=chart_info['data'],
346
+ time_var=self.summary.get('time_var'),
347
+ x_col=x_col,
348
+ boundaries=boundaries,
349
+ title_text=x_label,
350
+ is_faceted=False,
351
+ )
352
+ apply_x_axis_layout(
353
+ fig, layout,
354
+ font_size=getattr(self._theme, 'annotation_font_size', 12),
355
+ )
356
+
357
+ # Force categorical axis — control chart x-axes are always ordinal
358
+ # (evenly spaced in sequence order), never continuous numeric
359
+ if x_col:
360
+ fig.update_xaxes(type='category')
361
+
362
+ # Hide the legend when there is only one user-facing trace — a
363
+ # single-series 'X' chip in the top-right is redundant and
364
+ # competes with the limits annotation for header space.
365
+ legend_traces = [t for t in fig.data if getattr(t, 'showlegend', True) is not False]
366
+ show_legend = len(legend_traces) > 1
367
+ fig.update_layout(
368
+ width=width,
369
+ height=height,
370
+ yaxis_title=y_label,
371
+ hovermode='x unified',
372
+ showlegend=show_legend,
373
+ )
374
+
375
+ return fig
376
+
377
+ # -----------------------------------------------------------------
378
+ # Faceted charts
379
+ # -----------------------------------------------------------------
380
+
381
+ def _plot_faceted( # noqa: C901
382
+ self,
383
+ charts: dict,
384
+ ncols: int,
385
+ highlight_signals: bool,
386
+ show_limits: bool,
387
+ show_limit_values: bool,
388
+ show_zones: bool,
389
+ show_rules: bool,
390
+ show_stats: bool,
391
+ width: int,
392
+ height: int,
393
+ xaxis_title: str | None = None,
394
+ yaxis_title: str | None = None,
395
+ shared_yaxis: bool = True,
396
+ yaxis_padding: float = 0.05,
397
+ vertical_spacing: float = 0.15,
398
+ per_type_shared: bool = False,
399
+ ) -> go.Figure:
400
+ """Create faceted control charts."""
401
+ n_charts = len(charts)
402
+ nrows = (n_charts + ncols - 1) // ncols
403
+
404
+ subplot_titles = [self._generate_subplot_title(name, charts[name]) for name in charts]
405
+
406
+ # Dynamic spacing
407
+ v_spacing, h_spacing = self._calculate_spacing(
408
+ nrows,
409
+ ncols,
410
+ vertical_spacing,
411
+ )
412
+
413
+ fig = make_subplots(
414
+ rows=nrows,
415
+ cols=ncols,
416
+ subplot_titles=subplot_titles,
417
+ vertical_spacing=v_spacing,
418
+ horizontal_spacing=h_spacing,
419
+ )
420
+
421
+ # Detect if all histograms
422
+ all_histograms = all(ci.get('metadata', {}).get('chart_type') == 'Histogram' for ci in charts.values())
423
+
424
+ # Determine axis labels
425
+ first_info = next(iter(charts.values()))
426
+ first_data = first_info['data']
427
+
428
+ if all_histograms:
429
+ value_col = first_info.get('metadata', {}).get('value_col')
430
+ x_label = xaxis_title or value_col
431
+ y_label = yaxis_title or 'Count'
432
+ else:
433
+ x_col = self._get_x_column(first_data)
434
+ first_name = next(iter(charts.keys()))
435
+ x_label = xaxis_title or self._get_xaxis_label(x_col, first_name)
436
+ y_label = yaxis_title or self._get_yaxis_label(None, first_info.get('metadata', {}).get('chart_type'))
437
+
438
+ # Shared binning / y-range
439
+ global_y_range = None
440
+ histogram_y_range = None
441
+ histogram_bin_edges = None
442
+
443
+ if all_histograms:
444
+ histogram_bin_edges, _ = self._calculate_histogram_bin_edges(charts)
445
+
446
+ if shared_yaxis:
447
+ if all_histograms:
448
+ histogram_y_range = self._calculate_histogram_yrange(
449
+ charts,
450
+ histogram_bin_edges,
451
+ )
452
+ else:
453
+ global_y_range = self._calculate_global_yrange(charts, yaxis_padding)
454
+
455
+ theme = self._theme
456
+
457
+ # Pass 1: render each panel and compute its x-axis layout.
458
+ # We defer applying the layout until pass 2 because title visibility
459
+ # depends on grid position (bottom row) AND on whether the layout
460
+ # produced a cell band — which we only know after compute_x_axis_layout.
461
+ panel_layouts: dict[int, Any] = {}
462
+ panel_x_cols: dict[int, str | None] = {}
463
+
464
+ for idx, (chart_name, chart_info) in enumerate(charts.items()):
465
+ row = idx // ncols + 1
466
+ col = idx % ncols + 1
467
+ metadata = chart_info.get('metadata', {})
468
+
469
+ if metadata.get('chart_type') == 'Histogram':
470
+ render_histogram(
471
+ fig,
472
+ chart_info,
473
+ theme,
474
+ show_stats=show_stats,
475
+ row=row,
476
+ col=col,
477
+ shared_bin_edges=histogram_bin_edges,
478
+ is_faceted=True,
479
+ )
480
+ panel_x_cols[idx] = None
481
+ continue
482
+
483
+ data = chart_info['data']
484
+ x_col = self._get_x_column(data)
485
+ panel_x_cols[idx] = x_col
486
+ center_key = self._get_center_key(chart_info['statistics'])
487
+
488
+ spec = build_render_spec(chart_info, chart_name, x_col, center_key)
489
+ ctx = build_render_context(
490
+ spec,
491
+ chart_info,
492
+ chart_name,
493
+ theme,
494
+ self.result,
495
+ highlight_signals=highlight_signals,
496
+ show_limits=show_limits,
497
+ show_limit_values=show_limit_values,
498
+ show_zones=show_zones,
499
+ show_rules=show_rules,
500
+ show_stats=show_stats,
501
+ is_faceted=True,
502
+ )
503
+
504
+ render_control_chart(fig, ctx, row=row, col=col, nrows=nrows, ncols=ncols)
505
+
506
+ # Compute (don't apply yet) the panel's x-axis layout.
507
+ boundaries = parse_lane_boundaries(metadata.get('lane_boundaries'))
508
+ if isinstance(boundaries, StratifiedBoundaries):
509
+ raise PlotError(
510
+ 'Stratified lane_boundaries leaked into a faceted panel render. '
511
+ 'Caller (focus() or _get_stratified_charts) must unpack per-stratum '
512
+ 'boundaries before delegating to the plotter.'
513
+ )
514
+ panel_layouts[idx] = compute_x_axis_layout(
515
+ data=data,
516
+ time_var=self.summary.get('time_var'),
517
+ x_col=x_col,
518
+ boundaries=boundaries,
519
+ title_text=x_label,
520
+ is_faceted=True,
521
+ )
522
+
523
+ # Figure-level layout (width/height/hover).
524
+ fig.update_layout(width=width, height=height, hovermode='closest')
525
+
526
+ # Pass 2: apply per-panel layouts + axis styling.
527
+ # Title visibility rule: bottom row, OR panels with a cell band
528
+ # (the band needs the title as its header).
529
+ axis_style_y = dict(
530
+ showline=theme.show_axis_line,
531
+ linecolor=theme.axis_line_color,
532
+ linewidth=theme.axis_line_width,
533
+ showgrid=theme.show_grid,
534
+ gridcolor=theme.grid_color,
535
+ gridwidth=theme.grid_width,
536
+ )
537
+ axis_style_x_base = dict(
538
+ showline=theme.show_axis_line,
539
+ linecolor=theme.axis_line_color,
540
+ linewidth=theme.axis_line_width,
541
+ showgrid=theme.show_grid,
542
+ gridcolor=theme.grid_color,
543
+ gridwidth=theme.grid_width,
544
+ )
545
+
546
+ font_size = getattr(theme, 'annotation_font_size', 12)
547
+ for idx in range(n_charts):
548
+ r = idx // ncols + 1
549
+ c = idx % ncols + 1
550
+ is_bottom = (r == nrows) or (idx + ncols >= n_charts)
551
+ is_leftmost = c == 1
552
+ panel_xcol = panel_x_cols.get(idx)
553
+
554
+ layout_obj = panel_layouts.get(idx)
555
+ if layout_obj is not None:
556
+ show_x_title = is_bottom or layout_obj.has_cell_band
557
+ apply_x_axis_layout(
558
+ fig, layout_obj,
559
+ row=r, col=c, ncols=ncols,
560
+ font_size=font_size,
561
+ show_title=show_x_title,
562
+ )
563
+
564
+ # Theme-driven axis styling, applied to every panel.
565
+ fig.update_xaxes(
566
+ **axis_style_x_base,
567
+ **({'type': 'category'} if panel_xcol else {}),
568
+ row=r,
569
+ col=c,
570
+ )
571
+ fig.update_yaxes(
572
+ title_text=y_label if is_leftmost else None,
573
+ **axis_style_y,
574
+ row=r,
575
+ col=c,
576
+ )
577
+
578
+ if histogram_y_range is not None:
579
+ fig.update_yaxes(range=histogram_y_range, autorange=False)
580
+ elif global_y_range is not None:
581
+ fig.update_yaxes(range=global_y_range, autorange=False)
582
+ elif per_type_shared:
583
+ # Group charts by base type and share y-range within each group
584
+ type_groups: dict[str, dict] = {}
585
+ for chart_name, chart_info in charts.items():
586
+ base = self._get_base_chart_type(chart_name)
587
+ type_groups.setdefault(base, {})[chart_name] = chart_info
588
+
589
+ chart_names_list = list(charts.keys())
590
+ for _base_type, group_charts in type_groups.items():
591
+ if all(c.get('metadata', {}).get('chart_type') == 'Histogram' for c in group_charts.values()):
592
+ continue
593
+ y_range = self._calculate_global_yrange(group_charts, yaxis_padding)
594
+ if y_range is not None:
595
+ for cn in group_charts:
596
+ idx = chart_names_list.index(cn)
597
+ r = idx // ncols + 1
598
+ c = idx % ncols + 1
599
+ fig.update_yaxes(
600
+ range=y_range,
601
+ autorange=False,
602
+ row=r,
603
+ col=c,
604
+ )
605
+
606
+ return fig
607
+
608
+ # -----------------------------------------------------------------
609
+ # Histogram (standalone)
610
+ # -----------------------------------------------------------------
611
+
612
+ def _plot_histogram(
613
+ self,
614
+ chart_info: dict,
615
+ chart_name: str,
616
+ show_stats: bool = True,
617
+ width: int = 1000,
618
+ height: int = 400,
619
+ xaxis_title: str | None = None,
620
+ yaxis_title: str | None = None,
621
+ ) -> go.Figure:
622
+ """Render standalone histogram with optional mean/std lines."""
623
+ fig = go.Figure()
624
+ render_histogram(
625
+ fig,
626
+ chart_info,
627
+ self._theme,
628
+ show_stats=show_stats,
629
+ is_faceted=False,
630
+ )
631
+
632
+ metadata = chart_info.get('metadata', {})
633
+ value_col = metadata.get('value_col')
634
+ x_label = xaxis_title or value_col
635
+ y_label = yaxis_title or 'Count'
636
+
637
+ fig.update_layout(
638
+ width=width,
639
+ height=height,
640
+ xaxis_title=x_label,
641
+ yaxis_title=y_label,
642
+ showlegend=False,
643
+ bargap=0.05,
644
+ )
645
+ return fig
646
+
647
+ # -----------------------------------------------------------------
648
+ # Chart listing
649
+ # -----------------------------------------------------------------
650
+
651
+ def list_charts(self) -> list[str]:
652
+ """Get list of available charts."""
653
+ return list(self.charts.keys())
654
+
655
+ # -----------------------------------------------------------------
656
+ # Effects charts
657
+ # -----------------------------------------------------------------
658
+
659
+ def _plot_effects_chart(
660
+ self,
661
+ chart_type: str,
662
+ width: int = 1000,
663
+ height: int | None = None,
664
+ title: str | None = None,
665
+ ) -> ControlChartFigure:
666
+ """Route to appropriate effects chart function."""
667
+ from .effects_charts import (
668
+ create_factor_effects_chart,
669
+ create_factor_interaction_chart,
670
+ create_main_effects_chart,
671
+ create_time_effects_chart,
672
+ create_time_interaction_chart,
673
+ )
674
+
675
+ theme = self._theme
676
+ effects = self.result.effects
677
+ interactions = self.result.interactions
678
+
679
+ if chart_type == 'Effects':
680
+ if not self.result.has_effects:
681
+ raise ValidationError(
682
+ 'Effects not available for this analysis.\nEffects require factors in the analysis specification.'
683
+ )
684
+ fig = create_main_effects_chart(
685
+ effects=effects,
686
+ theme=theme,
687
+ width=width,
688
+ height=height,
689
+ )
690
+
691
+ elif chart_type == 'MainEffects':
692
+ if not self.result.has_effects:
693
+ raise ValidationError(
694
+ 'Effects not available for this analysis.\nEffects require factors in the analysis specification.'
695
+ )
696
+ fig = create_factor_effects_chart(
697
+ effects=effects,
698
+ theme=theme,
699
+ width=width,
700
+ height=height or 500,
701
+ )
702
+
703
+ elif chart_type == 'TimeEffects':
704
+ if 'time' not in effects and not any(
705
+ isinstance(v, pd.DataFrame) and 'PT_ME' in v.columns for v in effects.values()
706
+ ):
707
+ raise ValidationError(
708
+ 'Time effects not available for this analysis.\nThis requires a time variable in the analysis.'
709
+ )
710
+ fig = create_time_effects_chart(
711
+ effects=effects,
712
+ theme=theme,
713
+ width=width,
714
+ height=height,
715
+ )
716
+
717
+ elif chart_type == 'TimeInteraction':
718
+ if 'factor_time' not in interactions:
719
+ raise ValidationError(
720
+ 'Time interaction not available for this analysis.\nThis requires both factors and time variable.'
721
+ )
722
+ factors = self.summary.get('grouping_vars', [])
723
+ time_var = self.summary.get('time_var')
724
+ if not factors or not time_var:
725
+ raise ValidationError('Cannot create time interaction chart.\nRequires both factors and time variable.')
726
+ fig = create_time_interaction_chart(
727
+ interactions=interactions,
728
+ effects=effects,
729
+ factors=factors,
730
+ time_var=time_var,
731
+ dataset=self.result.dataset,
732
+ theme=theme,
733
+ width=width,
734
+ height=height or 500,
735
+ )
736
+
737
+ elif chart_type == 'FactorInteraction':
738
+ if 'factor_factor' not in interactions:
739
+ raise ValidationError(
740
+ 'Factor interaction not available for this analysis.\n'
741
+ 'This requires at least 2 factors in the analysis.'
742
+ )
743
+ factors = self.summary.get('grouping_vars', [])
744
+ if len(factors) < 2:
745
+ raise ValidationError(f'Factor interaction requires at least 2 factors, got {len(factors)}.')
746
+ fig = create_factor_interaction_chart(
747
+ interactions=interactions,
748
+ factors=factors,
749
+ theme=theme,
750
+ width=width,
751
+ height=height or 600,
752
+ )
753
+
754
+ else:
755
+ raise ChartNotAvailableError(
756
+ f"Unknown effects chart type: '{chart_type}'.\n"
757
+ f"Options: 'Effects', 'MainEffects', 'TimeEffects', "
758
+ f"'TimeInteraction', 'FactorInteraction'",
759
+ chart=chart_type,
760
+ )
761
+
762
+ fig = apply_theme(fig, theme)
763
+ if title:
764
+ fig.update_layout(title=title)
765
+ return ControlChartFigure(fig, self.result)
766
+
767
+ # -----------------------------------------------------------------
768
+ # plot_effects (legacy bar-chart path)
769
+ # -----------------------------------------------------------------
770
+
771
+ def plot_effects( # noqa: C901
772
+ self,
773
+ effect_type: str = 'factor',
774
+ theme: str | ChartTheme = 'processbehavior',
775
+ width: int = 800,
776
+ height: int = 500,
777
+ ) -> ControlChartFigure:
778
+ """Create bar chart of main effects.
779
+
780
+ Parameters
781
+ ----------
782
+ effect_type : str, default 'factor'
783
+ Type of effects: 'factor', 'time', or 'all'.
784
+ theme : str or ChartTheme
785
+ Visual theme.
786
+ width, height : int
787
+ Figure dimensions.
788
+
789
+ Returns
790
+ -------
791
+ ControlChartFigure
792
+ """
793
+ if not self.result.has_effects:
794
+ raise ValidationError(
795
+ 'Effects not available for this analysis.\nEffects require SDS >= 2 (multiple factor levels).'
796
+ )
797
+
798
+ effects = self.result.effects
799
+ theme = get_theme(theme) if isinstance(theme, str) else theme
800
+
801
+ if effect_type == 'all':
802
+ fig = self._effects_all(effects, theme, width, height)
803
+ elif effect_type == 'factor':
804
+ fig = self._effects_factor(effects, theme, width, height)
805
+ elif effect_type == 'time':
806
+ fig = self._effects_time(effects, theme, width, height)
807
+ else:
808
+ raise ValidationError(f"Invalid effect_type: '{effect_type}'.\nOptions: 'factor', 'time', 'all'")
809
+
810
+ fig = apply_theme(fig, theme)
811
+ return ControlChartFigure(fig, self.result)
812
+
813
+ # -----------------------------------------------------------------
814
+ # Residuals (delegate)
815
+ # -----------------------------------------------------------------
816
+
817
+ def plot_residuals(
818
+ self,
819
+ residual_type: str = 'R1',
820
+ plot_type: str = 'all',
821
+ theme: str | ChartTheme = 'processbehavior',
822
+ width: int = 1200,
823
+ height: int = 400,
824
+ ) -> ControlChartFigure:
825
+ """Create residual diagnostic plots.
826
+
827
+ Parameters
828
+ ----------
829
+ residual_type : str, default 'R1'
830
+ Which residual to plot ('R1'-'R5').
831
+ plot_type : str, default 'all'
832
+ 'histogram', 'qq', 'sequence', or 'all'.
833
+ theme : str or ChartTheme
834
+ Visual theme.
835
+ width, height : int
836
+ Figure dimensions.
837
+
838
+ Returns
839
+ -------
840
+ ControlChartFigure
841
+ """
842
+ from .residuals import plot_residuals as _plot_residuals
843
+
844
+ return _plot_residuals(
845
+ self.result,
846
+ residual_type=residual_type,
847
+ plot_type=plot_type,
848
+ theme=theme,
849
+ width=width,
850
+ height=height,
851
+ )
852
+
853
+ # -----------------------------------------------------------------
854
+ # Report (delegate)
855
+ # -----------------------------------------------------------------
856
+
857
+ def generate_report(
858
+ self,
859
+ filepath: str,
860
+ include_charts: bool = True,
861
+ include_residuals: bool = True,
862
+ include_effects: bool = True,
863
+ include_summary: bool = True,
864
+ theme: str | ChartTheme = 'processbehavior',
865
+ width: int = 1200,
866
+ title: str | None = None,
867
+ ) -> None:
868
+ """Generate comprehensive HTML report."""
869
+ from .report import generate_report as _generate_report
870
+
871
+ _generate_report(
872
+ self.result,
873
+ filepath=filepath,
874
+ include_charts=include_charts,
875
+ include_residuals=include_residuals,
876
+ include_effects=include_effects,
877
+ include_summary=include_summary,
878
+ theme=theme,
879
+ width=width,
880
+ title=title,
881
+ )
882
+
883
+ # =================================================================
884
+ # Private helpers
885
+ # =================================================================
886
+
887
+ def _resolve_charts(
888
+ self,
889
+ chart: str | None,
890
+ facet: bool,
891
+ ) -> dict:
892
+ """Determine which charts to plot."""
893
+ if chart:
894
+ chart_info = self.charts[chart]
895
+ if 'strata' in chart_info and chart_info['strata']:
896
+ charts = self._get_stratified_charts()
897
+ return {k: v for k, v in charts.items() if v.get('metadata', {}).get('original_chart') == chart}
898
+ return {chart: chart_info}
899
+
900
+ if facet or self.summary.get('is_stratified', False):
901
+ return self._get_stratified_charts()
902
+
903
+ # Standard charts
904
+ standard = {k: v for k, v in self.charts.items() if k in ['Xbar', 'S', 'X', 'mR', 'Histogram']}
905
+ if not standard and len(self.charts) == 1:
906
+ standard = dict(self.charts)
907
+ return standard
908
+
909
+ @staticmethod
910
+ def _resolve_height(
911
+ height: int | None,
912
+ width: int,
913
+ aspect_ratio: float | None,
914
+ n_charts: int,
915
+ ncols: int,
916
+ ) -> int:
917
+ """Calculate figure height."""
918
+ if height is not None:
919
+ return height
920
+ if aspect_ratio is not None:
921
+ return int(width / aspect_ratio)
922
+ nrows = (n_charts + ncols - 1) // ncols
923
+ if n_charts == 1:
924
+ return 400
925
+ height_per_row = 450 if nrows <= 3 else 400
926
+ return nrows * height_per_row
927
+
928
+ @staticmethod
929
+ def _calculate_spacing(
930
+ nrows: int,
931
+ ncols: int,
932
+ vertical_spacing: float,
933
+ ) -> tuple[float, float]:
934
+ """Calculate dynamic subplot spacing."""
935
+ if nrows > 1:
936
+ # Cap total gap fraction to preserve plot area
937
+ max_total_gap = 0.4 # at most 40% of figure for gaps
938
+ max_v = max_total_gap / (nrows - 1)
939
+ desired_v = 0.20 if nrows <= 3 else 0.12
940
+ v_spacing = min(max(vertical_spacing, desired_v), max_v)
941
+ else:
942
+ v_spacing = vertical_spacing
943
+ if ncols > 1:
944
+ max_h = 1.0 / (ncols - 1) - 0.01
945
+ h_spacing = min(0.10, max_h)
946
+ else:
947
+ h_spacing = 0.10
948
+ return v_spacing, h_spacing
949
+
950
+ # ---- Data column helpers ----
951
+
952
+ def _get_value_column(self, chart_info: ChartPayload, chart_name: str) -> str:
953
+ if 'metadata' not in chart_info:
954
+ raise PlotError(f"Chart '{chart_name}' missing metadata. All charts must have metadata with 'value_col'.")
955
+ return chart_info['metadata']['value_col']
956
+
957
+ @staticmethod
958
+ def _get_base_chart_type(chart_name: str) -> str:
959
+ return chart_name.split('_')[0]
960
+
961
+ @staticmethod
962
+ def _reorder_companion_pairs(
963
+ charts: dict,
964
+ ) -> tuple[dict, int | None]:
965
+ """Reorder stratified companion charts into paired rows.
966
+
967
+ Detects stratified companion pairs (Xbar+S or X+mR with ≥ 4 charts)
968
+ and interleaves them so each stratum's pair is side-by-side:
969
+ ``[Xbar_1_1, S_1_1, Xbar_1_2, S_1_2, ...]``
970
+
971
+ Returns ``(reordered_dict, 2)`` to force ncols=2, or
972
+ ``(charts, None)`` if not applicable.
973
+ """
974
+ if len(charts) < 4:
975
+ return charts, None
976
+
977
+ # Determine base types present
978
+ base_types: dict[str, list[str]] = {}
979
+ for name in charts:
980
+ base = name.split('_')[0]
981
+ base_types.setdefault(base, []).append(name)
982
+
983
+ if len(base_types) != 2:
984
+ return charts, None
985
+
986
+ type_set = set(base_types.keys())
987
+ if type_set not in ({'Xbar', 'S'}, {'X', 'mR'}):
988
+ return charts, None
989
+
990
+ # Extract stratum suffixes: everything after the first '_'
991
+ # e.g. "Xbar_1_2" -> "1_2"
992
+ def suffix(name: str) -> str:
993
+ return name.split('_', 1)[1] if '_' in name else ''
994
+
995
+ # Determine primary/secondary order
996
+ if 'Xbar' in type_set:
997
+ primary, secondary = 'Xbar', 'S'
998
+ else:
999
+ primary, secondary = 'X', 'mR'
1000
+
1001
+ # Build lookup by suffix for each type
1002
+ secondary_by_suffix = {suffix(n): n for n in base_types[secondary]}
1003
+
1004
+ # Interleave using primary order (preserves original stratum ordering)
1005
+ reordered: dict = {}
1006
+ for name in base_types[primary]:
1007
+ sfx = suffix(name)
1008
+ reordered[name] = charts[name]
1009
+ sec_name = secondary_by_suffix.get(sfx)
1010
+ if sec_name:
1011
+ reordered[sec_name] = charts[sec_name]
1012
+
1013
+ # Append any secondary keys without matching primary (shouldn't happen)
1014
+ for name in base_types[secondary]:
1015
+ if name not in reordered:
1016
+ reordered[name] = charts[name]
1017
+
1018
+ return reordered, 2
1019
+
1020
+ def _get_x_column(self, data: pd.DataFrame) -> str | None:
1021
+ time_var = self.summary.get('time_var')
1022
+ if time_var and time_var in data.columns:
1023
+ if not data[time_var].is_unique:
1024
+ return None
1025
+ return time_var
1026
+ for col in ('subgroup', 'rsg', 'group'):
1027
+ if col in data.columns and data[col].is_unique:
1028
+ return col
1029
+ # For explicit by=[single factor] in Xbar/S paths, the grouping
1030
+ # column may be the original factor name (e.g., "FACTOR 1").
1031
+ metric_cols = {
1032
+ 'xbar',
1033
+ 's',
1034
+ 'mr',
1035
+ 'center',
1036
+ 'lpl',
1037
+ 'upl',
1038
+ 'beyond_limits',
1039
+ 'obs_id',
1040
+ 'groups',
1041
+ 'n',
1042
+ 'N',
1043
+ }
1044
+ for col in data.columns:
1045
+ if col in metric_cols:
1046
+ continue
1047
+ if data[col].is_unique:
1048
+ return col
1049
+ return None
1050
+
1051
+ @staticmethod
1052
+ def _get_center_key(stats: dict) -> str | None:
1053
+ return 'center' if 'center' in stats else None
1054
+
1055
+ # ---- Stratification ----
1056
+
1057
+ def _get_stratified_charts(self) -> dict:
1058
+ """Expand stratified charts into per-stratum charts for faceting."""
1059
+ stratified: dict = {}
1060
+
1061
+ for name, chart_info in self.charts.items():
1062
+ if 'strata' in chart_info and chart_info['strata']:
1063
+ strata = chart_info['strata']
1064
+ combined_data = chart_info['data']
1065
+ nested_stats = chart_info['statistics']
1066
+ metadata = chart_info.get('metadata', {})
1067
+ stratify_by = metadata.get('stratify_by', [])
1068
+
1069
+ if len(stratify_by) == 1:
1070
+ stratify_col = stratify_by[0]
1071
+ elif len(stratify_by) > 1:
1072
+ combined_data = combined_data.copy()
1073
+ combined_data['_stratify_key'] = encode_rsg_series(combined_data, stratify_by)
1074
+ stratify_col = '_stratify_key'
1075
+ else:
1076
+ stratify_col = None
1077
+
1078
+ if stratify_col is not None and stratify_col in combined_data.columns:
1079
+ for stratum in strata:
1080
+ mask = combined_data[stratify_col] == stratum
1081
+ stratum_data = combined_data[mask].copy().reset_index(drop=True)
1082
+ stratum_stats = nested_stats.get(stratum, {})
1083
+
1084
+ stratum_str = encode_rsg(stratum)
1085
+ stratum_display = str(stratum)
1086
+ expanded_name = f'{name}_{stratum_str}'
1087
+
1088
+ all_lb = metadata.get('lane_boundaries')
1089
+ stratum_lb = all_lb.get(stratum) if isinstance(all_lb, dict) else None
1090
+
1091
+ stratified[expanded_name] = {
1092
+ 'data': stratum_data,
1093
+ 'statistics': stratum_stats,
1094
+ 'metadata': {
1095
+ **metadata,
1096
+ 'original_chart': name,
1097
+ 'stratum': stratum,
1098
+ 'stratum_display': stratum_display,
1099
+ 'lane_boundaries': stratum_lb,
1100
+ },
1101
+ }
1102
+ else:
1103
+ stratified[name] = chart_info
1104
+
1105
+ elif '_' in str(name) or any(
1106
+ key in chart_info.get('metadata', {}) for key in ['stratum', 'level', 'group']
1107
+ ):
1108
+ stratified[name] = chart_info
1109
+
1110
+ return stratified if stratified else self.charts
1111
+
1112
+
1113
+ # ---- Y-range / histogram helpers ----
1114
+
1115
+ def _calculate_global_yrange(
1116
+ self,
1117
+ charts: dict,
1118
+ padding: float = 0.05,
1119
+ ) -> list[float]:
1120
+ """Global y-axis range across all charts."""
1121
+ global_min = float('inf')
1122
+ global_max = float('-inf')
1123
+
1124
+ for chart_name, chart_info in charts.items():
1125
+ data = chart_info['data']
1126
+ stats = chart_info['statistics']
1127
+ value_col = self._get_value_column(chart_info, chart_name)
1128
+
1129
+ global_min = min(global_min, data[value_col].min())
1130
+ global_max = max(global_max, data[value_col].max())
1131
+
1132
+ ucl = stats.get('upl')
1133
+ lcl = stats.get('lpl')
1134
+ if ucl is not None:
1135
+ global_max = max(global_max, ucl)
1136
+ if lcl is not None:
1137
+ global_min = min(global_min, lcl)
1138
+
1139
+ data_range = global_max - global_min
1140
+ pad = data_range * padding
1141
+ return [global_min - pad, global_max + pad]
1142
+
1143
+ @staticmethod
1144
+ def _calculate_histogram_bin_edges(charts: dict) -> tuple[np.ndarray, int]:
1145
+ """Shared bin edges across all histogram facets."""
1146
+ global_min = float('inf')
1147
+ global_max = float('-inf')
1148
+ n_bins = None
1149
+ total_n = 0
1150
+
1151
+ for chart_info in charts.values():
1152
+ metadata = chart_info.get('metadata', {})
1153
+ if metadata.get('chart_type') != 'Histogram':
1154
+ continue
1155
+ data = chart_info['data']
1156
+ value_col = metadata.get('value_col')
1157
+ n_bins = metadata.get('bins')
1158
+ if value_col is None or value_col not in data.columns:
1159
+ continue
1160
+ values = data[value_col].dropna()
1161
+ if len(values) == 0:
1162
+ continue
1163
+ total_n += len(values)
1164
+ global_min = min(global_min, values.min())
1165
+ global_max = max(global_max, values.max())
1166
+
1167
+ # Auto-bin via Sturges' rule when bins is None
1168
+ if n_bins is None:
1169
+ import math
1170
+
1171
+ n_bins = max(5, int(math.ceil(1 + math.log2(total_n)))) if total_n > 0 else 10
1172
+
1173
+ if global_min == float('inf') or global_max == float('-inf'):
1174
+ return np.linspace(0, 1, n_bins + 1), n_bins
1175
+ return np.linspace(global_min, global_max, n_bins + 1), n_bins
1176
+
1177
+ @staticmethod
1178
+ def _calculate_histogram_yrange(
1179
+ charts: dict,
1180
+ bin_edges: np.ndarray,
1181
+ padding: float = 0.20,
1182
+ ) -> list[float]:
1183
+ """Y-axis range for histogram facets."""
1184
+ max_count = 0
1185
+ for chart_info in charts.values():
1186
+ metadata = chart_info.get('metadata', {})
1187
+ if metadata.get('chart_type') != 'Histogram':
1188
+ continue
1189
+ data = chart_info['data']
1190
+ value_col = metadata.get('value_col')
1191
+ if value_col is None or value_col not in data.columns:
1192
+ continue
1193
+ values = data[value_col].dropna()
1194
+ if len(values) == 0:
1195
+ continue
1196
+ counts, _ = np.histogram(values, bins=bin_edges)
1197
+ max_count = max(max_count, counts.max())
1198
+ if max_count == 0:
1199
+ return [0, 1]
1200
+ return [0, max_count * (1 + padding)]
1201
+
1202
+ # ---- Title / label generation ----
1203
+
1204
+ @staticmethod
1205
+ def _format_chart_label(chart_type: str) -> str:
1206
+ """Append ' Chart' unless chart_type already ends with 'Chart'."""
1207
+ return chart_type if chart_type.endswith('Chart') else f'{chart_type} Chart'
1208
+
1209
+ def _generate_title(self, chart_name: str, chart_info: ChartPayload | None = None) -> str:
1210
+ response_var = self.summary.get('response_var', '')
1211
+ grouping_vars = self.summary.get('grouping_vars', [])
1212
+ chart_type = self._get_chart_type_display(chart_name)
1213
+
1214
+ parts = [self._format_chart_label(chart_type)]
1215
+
1216
+ # Use residual label if this is a residual chart
1217
+ metadata = chart_info.get('metadata', {}) if chart_info else {}
1218
+ residual_type = metadata.get('residual_type')
1219
+ if residual_type:
1220
+ recentered = metadata.get('recentered', False)
1221
+ label = _RESIDUAL_LABELS.get(residual_type, residual_type)
1222
+ suffix = f'{residual_type} ({label})'
1223
+ if recentered:
1224
+ suffix += ' Recentered'
1225
+ parts.append(f'of {suffix}')
1226
+ elif response_var:
1227
+ parts.append(f'of {response_var}')
1228
+
1229
+ if grouping_vars and len(grouping_vars) == 1:
1230
+ parts.append(f'by {grouping_vars[0]}')
1231
+ if '_' in chart_name and chart_name not in ['Xbar', 'S', 'X']:
1232
+ stratum = self._extract_stratum_name(chart_name)
1233
+ if stratum:
1234
+ parts.append(f'- {stratum}')
1235
+ return ' '.join(parts)
1236
+
1237
+ def _generate_subplot_title(
1238
+ self,
1239
+ chart_name: str,
1240
+ chart_info: ChartPayload | None = None,
1241
+ ) -> str:
1242
+ chart_type = self._get_chart_type_display(chart_name)
1243
+ if chart_info is not None:
1244
+ sd = chart_info.get('metadata', {}).get('stratum_display')
1245
+ if sd:
1246
+ return f'{chart_type} {sd}'
1247
+ if '_' in chart_name:
1248
+ stratum = self._extract_stratum_name(chart_name)
1249
+ if stratum:
1250
+ return f'{chart_type} {stratum}'
1251
+ return chart_type
1252
+
1253
+ @staticmethod
1254
+ def _get_chart_type_display(chart_name: str) -> str:
1255
+ display_names = {
1256
+ 'Xbar': 'X\u0304',
1257
+ 'S': 'S',
1258
+ 'X': 'Individuals Chart',
1259
+ 'mR': 'mR',
1260
+ 'Histogram': 'Histogram',
1261
+ }
1262
+ if chart_name in display_names:
1263
+ return display_names[chart_name]
1264
+ for key, display in display_names.items():
1265
+ if chart_name.startswith(key + '_'):
1266
+ return display
1267
+ raise PlotError(f'Unknown chart type: {chart_name}')
1268
+
1269
+ @staticmethod
1270
+ def _extract_stratum_name(chart_name: str) -> str | None:
1271
+ prefixes = ['Xbar_', 'S_', 'X_', 'mR_', 'Histogram_']
1272
+ result = chart_name
1273
+ for prefix in prefixes:
1274
+ if result.startswith(prefix):
1275
+ result = result[len(prefix) :]
1276
+ break
1277
+ result = result.replace('_', ' ')
1278
+ return result if result != chart_name else None
1279
+
1280
+ def _get_xaxis_label(
1281
+ self,
1282
+ x_col: str | None = None,
1283
+ chart_name: str | None = None,
1284
+ ) -> str:
1285
+ if x_col in ('subgroup', 'rsg', 'group'):
1286
+ base_type = chart_name.split('_')[0] if chart_name else ''
1287
+ if base_type in ('X', 'mR'):
1288
+ time_var = self.summary.get('time_var')
1289
+ if time_var:
1290
+ return time_var.replace('_', ' ').title()
1291
+ return 'Observation'
1292
+ return 'Subgroup'
1293
+ time_var = self.summary.get('time_var')
1294
+ if time_var and (x_col is None or x_col == time_var):
1295
+ return time_var.replace('_', ' ').title()
1296
+ if x_col is None:
1297
+ return 'Observation'
1298
+ return x_col.replace('_', ' ').title()
1299
+
1300
+ def _get_yaxis_label(self, value_col: str | None, chart_type: str | None = None) -> str:
1301
+ if chart_type == 'Xbar':
1302
+ return 'Sample Average'
1303
+ if chart_type == 'S':
1304
+ return 'Sample Standard Deviation'
1305
+ if chart_type == 'X':
1306
+ return 'Individual Value'
1307
+ if chart_type == 'mR':
1308
+ return 'Moving Range'
1309
+ response_var = self.summary.get('response_var')
1310
+ if response_var:
1311
+ return response_var.replace('_', ' ').title()
1312
+ if value_col:
1313
+ return value_col.capitalize()
1314
+ return 'Value'
1315
+
1316
+ def _get_data_legend_name(self) -> str:
1317
+ response_var = self.summary.get('response_var')
1318
+ if response_var:
1319
+ return response_var.replace('_', ' ').title()
1320
+ return 'Data'
1321
+
1322
+ # ---- Effects bar chart helpers (used by plot_effects) ----
1323
+
1324
+ @staticmethod
1325
+ def _effects_all(effects, theme, width, height):
1326
+ from plotly.subplots import make_subplots as _ms
1327
+
1328
+ factor_effects = []
1329
+ time_effects = None
1330
+ for name, data in effects.items():
1331
+ if 'PT_ME' in str(data.columns if hasattr(data, 'columns') else ''):
1332
+ time_effects = data
1333
+ elif isinstance(data, pd.DataFrame) and 'Main_Effect' in data.columns:
1334
+ factor_effects.append((name, data))
1335
+
1336
+ n_plots = len(factor_effects) + (1 if time_effects is not None else 0)
1337
+ if n_plots == 0:
1338
+ raise ValidationError('No effects data found to plot.')
1339
+
1340
+ fig = _ms(
1341
+ rows=1,
1342
+ cols=n_plots,
1343
+ subplot_titles=[n for n, _ in factor_effects] + (['Time Effects'] if time_effects is not None else []),
1344
+ horizontal_spacing=0.1,
1345
+ )
1346
+ c = 1
1347
+ for name, data in factor_effects:
1348
+ fc = data.columns[0]
1349
+ fig.add_trace(
1350
+ go.Bar(
1351
+ x=data[fc].astype(str),
1352
+ y=data['Main_Effect'],
1353
+ name=name,
1354
+ marker_color=theme.data_color,
1355
+ showlegend=False,
1356
+ ),
1357
+ row=1,
1358
+ col=c,
1359
+ )
1360
+ fig.update_xaxes(title_text=fc, row=1, col=c)
1361
+ fig.update_yaxes(title_text='Main Effect', row=1, col=c)
1362
+ c += 1
1363
+
1364
+ if time_effects is not None:
1365
+ tc = time_effects.columns[0]
1366
+ fig.add_trace(
1367
+ go.Bar(
1368
+ x=time_effects[tc].astype(str),
1369
+ y=time_effects['PT_ME'],
1370
+ name='Time',
1371
+ marker_color=theme.center_color,
1372
+ showlegend=False,
1373
+ ),
1374
+ row=1,
1375
+ col=c,
1376
+ )
1377
+ fig.update_xaxes(title_text=tc, row=1, col=c)
1378
+ fig.update_yaxes(title_text='Time Effect', row=1, col=c)
1379
+
1380
+ fig.update_layout(title='Main Effects Analysis', width=width, height=height)
1381
+ return fig
1382
+
1383
+ @staticmethod
1384
+ def _effects_factor(effects, theme, width, height):
1385
+ from plotly.subplots import make_subplots as _ms
1386
+
1387
+ factor_effects = [
1388
+ (n, d) for n, d in effects.items() if isinstance(d, pd.DataFrame) and 'Main_Effect' in d.columns
1389
+ ]
1390
+ if not factor_effects:
1391
+ raise ValidationError('No factor effects found.')
1392
+
1393
+ if len(factor_effects) == 1:
1394
+ name, data = factor_effects[0]
1395
+ fc = data.columns[0]
1396
+ fig = go.Figure()
1397
+ fig.add_trace(
1398
+ go.Bar(
1399
+ x=data[fc].astype(str),
1400
+ y=data['Main_Effect'],
1401
+ name='Effect',
1402
+ marker_color=theme.data_color,
1403
+ )
1404
+ )
1405
+ fig.add_hline(y=0, line_dash='dash', line_color=theme.center_color)
1406
+ fig.update_layout(
1407
+ title=f'Factor Main Effects: {name}',
1408
+ xaxis_title=fc,
1409
+ yaxis_title='Main Effect',
1410
+ width=width,
1411
+ height=height,
1412
+ )
1413
+ else:
1414
+ fig = _ms(
1415
+ rows=1,
1416
+ cols=len(factor_effects),
1417
+ subplot_titles=[n for n, _ in factor_effects],
1418
+ horizontal_spacing=0.1,
1419
+ )
1420
+ for c, (name, data) in enumerate(factor_effects, 1):
1421
+ fc = data.columns[0]
1422
+ fig.add_trace(
1423
+ go.Bar(
1424
+ x=data[fc].astype(str),
1425
+ y=data['Main_Effect'],
1426
+ name=name,
1427
+ marker_color=theme.data_color,
1428
+ showlegend=False,
1429
+ ),
1430
+ row=1,
1431
+ col=c,
1432
+ )
1433
+ fig.update_xaxes(title_text=fc, row=1, col=c)
1434
+ fig.update_yaxes(title_text='Main Effect', row=1, col=c)
1435
+ fig.update_layout(
1436
+ title='Factor Main Effects',
1437
+ width=width,
1438
+ height=height,
1439
+ )
1440
+ return fig
1441
+
1442
+ @staticmethod
1443
+ def _effects_time(effects, theme, width, height):
1444
+ time_effects = None
1445
+ for _name, data in effects.items():
1446
+ if isinstance(data, pd.DataFrame) and 'PT_ME' in data.columns:
1447
+ time_effects = data
1448
+ break
1449
+ if time_effects is None:
1450
+ raise ValidationError('Time effects not found.\nTime effects require time_var in analysis specification.')
1451
+ tc = time_effects.columns[0]
1452
+ fig = go.Figure()
1453
+ fig.add_trace(
1454
+ go.Bar(
1455
+ x=time_effects[tc].astype(str),
1456
+ y=time_effects['PT_ME'],
1457
+ name='Time Effect',
1458
+ marker_color=theme.center_color,
1459
+ )
1460
+ )
1461
+ fig.add_hline(y=0, line_dash='dash', line_color=theme.data_color)
1462
+ fig.update_layout(
1463
+ title='Time Main Effects',
1464
+ xaxis_title=tc,
1465
+ yaxis_title='Time Effect (PT_ME)',
1466
+ width=width,
1467
+ height=height,
1468
+ )
1469
+ return fig