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