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,640 @@
1
+ """
2
+ Effects visualization charts for ProcessBehavior analysis.
3
+
4
+ This module provides chart functions for visualizing main effects and interactions:
5
+ - create_main_effects_chart: Horizontal bar chart of all main effects (combined)
6
+ - create_factor_effects_chart: Vertical bar chart of factor main effects only
7
+ - create_time_effects_chart: Horizontal bar chart of time effects only
8
+ - create_time_interaction_chart: Line plot of factor × time interaction
9
+ - create_factor_interaction_chart: Line chart for factor × factor interaction
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ from collections.abc import Mapping
16
+ from typing import TYPE_CHECKING, Any
17
+
18
+ import pandas as pd
19
+ import plotly.graph_objects as go
20
+
21
+ from processbehavior.exceptions import ChartNotAvailableError, ValidationError
22
+
23
+ if TYPE_CHECKING:
24
+ from .themes import ChartTheme
25
+
26
+ logger = logging.getLogger(__name__)
27
+
28
+ _FACTOR_COLORS = [
29
+ '#FF6B6B', # Coral
30
+ '#4ECDC4', # Teal
31
+ '#45B7D1', # Sky blue
32
+ '#96CEB4', # Sage
33
+ ]
34
+
35
+ _INTERACTION_COLORS = _FACTOR_COLORS + [
36
+ '#FFEAA7', # Soft yellow
37
+ '#DDA0DD', # Plum
38
+ ]
39
+
40
+
41
+ def _is_main_effect_key(name: str) -> bool:
42
+ """Check if an effects dict key represents a main effect.
43
+
44
+ Convention: main effect keys end with '_MEs' suffix or are
45
+ exactly 'main_effect'. All other keys are filtered out when
46
+ building main effects charts.
47
+
48
+ See: EffectsCalculator output key naming.
49
+ """
50
+ return name.endswith('_MEs') or name == 'main_effect'
51
+
52
+
53
+ def create_main_effects_chart(
54
+ effects: Mapping[str, Any],
55
+ theme: ChartTheme,
56
+ factors: list[str] | None = None,
57
+ width: int = 1000,
58
+ height: int | None = None,
59
+ ) -> go.Figure:
60
+ """
61
+ Create horizontal bar chart showing all main effects.
62
+
63
+ Displays main effect magnitudes for each factor level, with factors
64
+ grouped together. Includes a vertical reference line at 0.
65
+
66
+ Parameters
67
+ ----------
68
+ effects : dict
69
+ Effects dictionary from result.effects containing factor main effects.
70
+ Expected keys: factor names (e.g., 'Lane', 'Phase') mapping to DataFrames
71
+ with columns [factor_name, 'Main_Effect']
72
+ theme : ChartTheme
73
+ Visual theme for styling
74
+ factors : list of str, optional
75
+ Specific factors to include. If None, includes all factors.
76
+ width : int, default 1000
77
+ Figure width in pixels
78
+ height : int, optional
79
+ Figure height in pixels. Auto-calculated if None.
80
+
81
+ Returns
82
+ -------
83
+ go.Figure
84
+ Plotly figure with horizontal bar chart
85
+
86
+ Raises
87
+ ------
88
+ ValueError
89
+ If no main effects are found in the ``effects`` dictionary.
90
+
91
+ Examples
92
+ --------
93
+ >>> fig = create_main_effects_chart(result.effects, theme)
94
+ >>> fig.show()
95
+ """
96
+ # Find factor effects (DataFrames with 'Main_Effect' column)
97
+ factor_effects = []
98
+ time_effects = None
99
+
100
+ for name, data in effects.items():
101
+ if not isinstance(data, pd.DataFrame):
102
+ continue
103
+
104
+ # Skip non-effect DataFrames (MEs scores, etc.)
105
+ if _is_main_effect_key(name):
106
+ continue
107
+
108
+ if 'Main_Effect' in data.columns and (factors is None or name in factors):
109
+ factor_effects.append((name, data))
110
+ elif 'PT_ME' in data.columns:
111
+ time_effects = (name, data)
112
+
113
+ if not factor_effects and time_effects is None:
114
+ raise ChartNotAvailableError(
115
+ f'No main effects found to plot.\nAvailable effects keys: {list(effects.keys())}',
116
+ chart='main_effects',
117
+ available=list(effects.keys()),
118
+ )
119
+
120
+ # Build combined data for horizontal bar chart
121
+ all_labels = []
122
+ all_values = []
123
+ all_colors = []
124
+
125
+ # Color palette for factors
126
+ factor_colors = [theme.data_color, theme.center_color] + _FACTOR_COLORS
127
+
128
+ for i, (factor_name, data) in enumerate(factor_effects):
129
+ factor_col = data.columns[0]
130
+ color = factor_colors[i % len(factor_colors)]
131
+
132
+ for _, row in data.iterrows():
133
+ label = f'{factor_name}: {row[factor_col]}'
134
+ all_labels.append(label)
135
+ all_values.append(row['Main_Effect'])
136
+ all_colors.append(color)
137
+
138
+ # Add time effects if present
139
+ if time_effects is not None:
140
+ name, data = time_effects
141
+ time_col = data.columns[0]
142
+ for _, row in data.iterrows():
143
+ label = f'Time: {row[time_col]}'
144
+ all_labels.append(label)
145
+ all_values.append(row['PT_ME'])
146
+ all_colors.append(theme.pattern_signal_color)
147
+
148
+ # Calculate height if not specified
149
+ if height is None:
150
+ height = max(400, 25 * len(all_labels) + 100)
151
+
152
+ # Create horizontal bar chart
153
+ fig = go.Figure()
154
+
155
+ fig.add_trace(
156
+ go.Bar(
157
+ y=all_labels,
158
+ x=all_values,
159
+ orientation='h',
160
+ marker_color=all_colors,
161
+ text=[f'{v:.3f}' for v in all_values],
162
+ textposition='outside',
163
+ hovertemplate='%{y}<br>Effect: %{x:.4f}<extra></extra>',
164
+ )
165
+ )
166
+
167
+ # Add vertical line at 0
168
+ fig.add_vline(x=0, line_dash='dash', line_color='gray', line_width=1)
169
+
170
+ fig.update_layout(
171
+ title='Main Effects',
172
+ xaxis_title='Effect Magnitude',
173
+ yaxis_title='',
174
+ width=width,
175
+ height=height,
176
+ yaxis=dict(autorange='reversed'), # Top-to-bottom order
177
+ showlegend=False,
178
+ )
179
+
180
+ return fig
181
+
182
+
183
+ def create_factor_effects_chart(
184
+ effects: Mapping[str, Any],
185
+ theme: ChartTheme,
186
+ factors: list[str] | None = None,
187
+ width: int = 1000,
188
+ height: int = 500,
189
+ ) -> go.Figure:
190
+ """
191
+ Create vertical bar chart showing factor main effects only.
192
+
193
+ Displays main effect magnitudes for each factor level, with factors
194
+ grouped together. Excludes time effects. Includes a horizontal
195
+ reference line at 0.
196
+
197
+ Parameters
198
+ ----------
199
+ effects : dict
200
+ Effects dictionary from result.effects containing factor main effects.
201
+ Expected keys: factor names (e.g., 'Lane', 'Phase') mapping to DataFrames
202
+ with columns [factor_name, 'Main_Effect']
203
+ theme : ChartTheme
204
+ Visual theme for styling
205
+ factors : list of str, optional
206
+ Specific factors to include. If None, includes all factors.
207
+ width : int, default 1000
208
+ Figure width in pixels
209
+ height : int, default 500
210
+ Figure height in pixels
211
+
212
+ Returns
213
+ -------
214
+ go.Figure
215
+ Plotly figure with vertical bar chart
216
+
217
+ Raises
218
+ ------
219
+ ValueError
220
+ If no factor main effects are found in the ``effects`` dictionary.
221
+
222
+ Examples
223
+ --------
224
+ >>> fig = create_factor_effects_chart(result.effects, theme)
225
+ >>> fig.show()
226
+ """
227
+ # Find factor effects (DataFrames with 'Main_Effect' column)
228
+ factor_effects = []
229
+
230
+ for name, data in effects.items():
231
+ if not isinstance(data, pd.DataFrame):
232
+ continue
233
+
234
+ # Skip non-effect DataFrames (MEs scores, etc.)
235
+ if _is_main_effect_key(name):
236
+ continue
237
+
238
+ if 'Main_Effect' in data.columns and (factors is None or name in factors):
239
+ factor_effects.append((name, data))
240
+
241
+ if not factor_effects:
242
+ raise ChartNotAvailableError(
243
+ f'No factor main effects found to plot.\nAvailable effects keys: {list(effects.keys())}',
244
+ chart='factor_effects',
245
+ available=list(effects.keys()),
246
+ )
247
+
248
+ # Build combined data for vertical bar chart
249
+ all_labels = []
250
+ all_values = []
251
+ all_colors = []
252
+
253
+ # Color palette for factors
254
+ factor_colors = [theme.data_color, theme.center_color] + _FACTOR_COLORS
255
+
256
+ for i, (factor_name, data) in enumerate(factor_effects):
257
+ factor_col = data.columns[0]
258
+ color = factor_colors[i % len(factor_colors)]
259
+
260
+ for _, row in data.iterrows():
261
+ label = f'{factor_name}: {row[factor_col]}'
262
+ all_labels.append(label)
263
+ all_values.append(row['Main_Effect'])
264
+ all_colors.append(color)
265
+
266
+ # Create vertical bar chart
267
+ fig = go.Figure()
268
+
269
+ fig.add_trace(
270
+ go.Bar(
271
+ x=all_labels,
272
+ y=all_values,
273
+ marker_color=all_colors,
274
+ text=[f'{v:.3f}' for v in all_values],
275
+ textposition='outside',
276
+ hovertemplate='%{x}<br>Effect: %{y:.4f}<extra></extra>',
277
+ )
278
+ )
279
+
280
+ # Add horizontal line at 0
281
+ fig.add_hline(y=0, line_dash='dash', line_color='gray', line_width=1)
282
+
283
+ fig.update_layout(
284
+ title='Factor Main Effects',
285
+ xaxis_title='Factor Level',
286
+ yaxis_title='Effect Magnitude',
287
+ width=width,
288
+ height=height,
289
+ showlegend=False,
290
+ )
291
+
292
+ return fig
293
+
294
+
295
+ def create_time_effects_chart(
296
+ effects: Mapping[str, Any], theme: ChartTheme, width: int = 1000, height: int | None = None
297
+ ) -> go.Figure:
298
+ """
299
+ Create horizontal bar chart showing time effects only.
300
+
301
+ Displays time effect magnitudes for each time period. Excludes factor
302
+ main effects. Includes a vertical reference line at 0.
303
+
304
+ Parameters
305
+ ----------
306
+ effects : dict
307
+ Effects dictionary from result.effects containing time effects.
308
+ Expected key: time variable name mapping to DataFrame with columns
309
+ [time_var, 'PT_ME']
310
+ theme : ChartTheme
311
+ Visual theme for styling
312
+ width : int, default 1000
313
+ Figure width in pixels
314
+ height : int, optional
315
+ Figure height in pixels. Auto-calculated if None.
316
+
317
+ Returns
318
+ -------
319
+ go.Figure
320
+ Plotly figure with horizontal bar chart
321
+
322
+ Raises
323
+ ------
324
+ ValueError
325
+ If no time effects (PT_ME — Period-Time Main Effect) are found
326
+ in the ``effects`` dictionary.
327
+
328
+ Examples
329
+ --------
330
+ >>> fig = create_time_effects_chart(result.effects, theme)
331
+ >>> fig.show()
332
+ """
333
+ # Find time effects (DataFrame with 'PT_ME' column)
334
+ time_effects = None
335
+
336
+ for name, data in effects.items():
337
+ if not isinstance(data, pd.DataFrame):
338
+ continue
339
+
340
+ if 'PT_ME' in data.columns:
341
+ time_effects = (name, data)
342
+ break
343
+
344
+ if time_effects is None:
345
+ raise ChartNotAvailableError(
346
+ 'No time effects found to plot.\n'
347
+ 'This requires a time variable in the analysis.\n'
348
+ f'Available effects keys: {list(effects.keys())}',
349
+ chart='time_effects',
350
+ available=list(effects.keys()),
351
+ )
352
+
353
+ # Build data for horizontal bar chart
354
+ all_labels = []
355
+ all_values = []
356
+
357
+ name, data = time_effects
358
+ time_col = data.columns[0]
359
+ for _, row in data.iterrows():
360
+ label = f'Time: {row[time_col]}'
361
+ all_labels.append(label)
362
+ all_values.append(row['PT_ME'])
363
+
364
+ # Calculate height if not specified
365
+ if height is None:
366
+ height = max(400, 25 * len(all_labels) + 100)
367
+
368
+ # Create horizontal bar chart
369
+ fig = go.Figure()
370
+
371
+ fig.add_trace(
372
+ go.Bar(
373
+ y=all_labels,
374
+ x=all_values,
375
+ orientation='h',
376
+ marker_color=theme.pattern_signal_color,
377
+ text=[f'{v:.3f}' for v in all_values],
378
+ textposition='outside',
379
+ hovertemplate='%{y}<br>Effect: %{x:.4f}<extra></extra>',
380
+ )
381
+ )
382
+
383
+ # Add vertical line at 0
384
+ fig.add_vline(x=0, line_dash='dash', line_color='gray', line_width=1)
385
+
386
+ fig.update_layout(
387
+ title='Time Effects',
388
+ xaxis_title='Effect Magnitude',
389
+ yaxis_title='',
390
+ width=width,
391
+ height=height,
392
+ yaxis=dict(autorange='reversed'), # Top-to-bottom order
393
+ showlegend=False,
394
+ )
395
+
396
+ return fig
397
+
398
+
399
+ def create_time_interaction_chart(
400
+ interactions: Mapping[str, Any],
401
+ effects: Mapping[str, Any],
402
+ factors: list[str],
403
+ time_var: str,
404
+ dataset: pd.DataFrame,
405
+ theme: ChartTheme,
406
+ width: int = 1000,
407
+ height: int = 500,
408
+ ) -> go.Figure:
409
+ """
410
+ Create line plot showing factor × time interaction.
411
+
412
+ Displays one line per factor level combination, with x-axis as time
413
+ and y-axis as the interaction effect. Non-parallel lines indicate
414
+ interaction between factors and time.
415
+
416
+ Parameters
417
+ ----------
418
+ interactions : dict
419
+ Interactions dictionary from result.interactions.
420
+ Expected key: 'factor_time' mapping to Series of PDC values.
421
+ effects : dict
422
+ Effects dictionary from result.effects.
423
+ factors : list of str
424
+ Factor variable names
425
+ time_var : str
426
+ Time variable name
427
+ dataset : pd.DataFrame
428
+ Full analysis dataset with factor and time columns
429
+ theme : ChartTheme
430
+ Visual theme for styling
431
+ width : int, default 1000
432
+ Figure width in pixels
433
+ height : int, default 500
434
+ Figure height in pixels
435
+
436
+ Returns
437
+ -------
438
+ go.Figure
439
+ Plotly figure with line plot
440
+
441
+ Raises
442
+ ------
443
+ ValueError
444
+ If ``'factor_time'`` key is missing from ``interactions``.
445
+ ValueError
446
+ If the PDC (Predictable Difference of Cell means) series length
447
+ does not match the dataset length.
448
+
449
+ Examples
450
+ --------
451
+ >>> fig = create_time_interaction_chart(
452
+ ... result.interactions, result.effects,
453
+ ... factors=['Lane'], time_var='Pull',
454
+ ... dataset=result.dataset, theme=theme
455
+ ... )
456
+ >>> fig.show()
457
+ """
458
+ if 'factor_time' not in interactions:
459
+ raise ChartNotAvailableError(
460
+ 'Factor × time interaction not available.\nThis requires both factors and time variable in the analysis.',
461
+ chart='factor_time_interaction',
462
+ available=list(interactions.keys()),
463
+ )
464
+
465
+ pdc = interactions['factor_time']
466
+
467
+ # Get unique factor levels and time points from dataset
468
+ # Build aggregated data: mean PDC per (factor_combo, time)
469
+ agg_cols = list(factors) + [time_var]
470
+
471
+ # Add PDC to dataset for aggregation
472
+ df = dataset.copy()
473
+ if len(pdc) != len(df):
474
+ raise ValueError(
475
+ f'Interaction chart requires PDC aligned to plotted data: len(pdc)={len(pdc)} != len(df)={len(df)}'
476
+ )
477
+ df['_pdc'] = pdc.values
478
+
479
+ # Aggregate to cell level
480
+ agg_data = df.groupby(agg_cols, observed=True)['_pdc'].mean().reset_index()
481
+
482
+ # Create combined factor key for grouping lines
483
+ if len(factors) == 1:
484
+ agg_data['_factor_key'] = agg_data[factors[0]].astype(str)
485
+ else:
486
+ agg_data['_factor_key'] = agg_data[list(factors)].apply(lambda x: '_'.join(str(v) for v in x), axis=1)
487
+
488
+ # Sort by time
489
+ agg_data = agg_data.sort_values(time_var)
490
+
491
+ # Create figure
492
+ fig = go.Figure()
493
+
494
+ # Color palette for factor levels
495
+ colors = [theme.data_color, theme.center_color] + _INTERACTION_COLORS
496
+
497
+ factor_keys = agg_data['_factor_key'].unique()
498
+
499
+ for i, factor_key in enumerate(factor_keys):
500
+ mask = agg_data['_factor_key'] == factor_key
501
+ subset = agg_data[mask]
502
+
503
+ fig.add_trace(
504
+ go.Scatter(
505
+ x=subset[time_var],
506
+ y=subset['_pdc'],
507
+ mode='lines+markers',
508
+ name=str(factor_key),
509
+ line=dict(color=colors[i % len(colors)], width=2),
510
+ marker=dict(size=8, color=colors[i % len(colors)]),
511
+ hovertemplate=f'{factor_key}<br>Time: %{{x}}<br>Effect: %{{y:.4f}}<extra></extra>',
512
+ )
513
+ )
514
+
515
+ # Add horizontal line at 0
516
+ fig.add_hline(y=0, line_dash='dash', line_color='gray', line_width=1)
517
+
518
+ factor_label = ', '.join(factors)
519
+ fig.update_layout(
520
+ title=f'Factor × Time Interaction ({factor_label})',
521
+ xaxis_title=time_var,
522
+ yaxis_title='Interaction Effect (PDC)',
523
+ width=width,
524
+ height=height,
525
+ legend_title=factor_label,
526
+ hovermode='closest',
527
+ )
528
+
529
+ return fig
530
+
531
+
532
+ def create_factor_interaction_chart(
533
+ interactions: Mapping[str, Any], factors: list[str], theme: ChartTheme, width: int = 800, height: int = 600
534
+ ) -> go.Figure:
535
+ """
536
+ Create line chart for factor × factor interaction.
537
+
538
+ Displays one line per factor2 level, with x-axis as factor1 levels
539
+ and y-axis as the interaction effect (Rx). Non-parallel lines indicate
540
+ interaction between the two factors.
541
+
542
+ Parameters
543
+ ----------
544
+ interactions : dict
545
+ Interactions dictionary from result.interactions.
546
+ Expected key: 'factor_factor' mapping to DataFrame with
547
+ columns [factor1, factor2, 'Rx']
548
+ factors : list of str
549
+ Factor variable names (at least 2)
550
+ theme : ChartTheme
551
+ Visual theme for styling
552
+ width : int, default 800
553
+ Figure width in pixels
554
+ height : int, default 600
555
+ Figure height in pixels
556
+
557
+ Returns
558
+ -------
559
+ go.Figure
560
+ Plotly figure with line chart
561
+
562
+ Raises
563
+ ------
564
+ ValueError
565
+ If ``'factor_factor'`` key is missing from ``interactions``.
566
+ ValueError
567
+ If fewer than 2 factors are provided.
568
+ ValueError
569
+ If expected factor columns are not found in the interaction data.
570
+
571
+ Examples
572
+ --------
573
+ >>> fig = create_factor_interaction_chart(
574
+ ... result.interactions,
575
+ ... factors=['Lane', 'Phase'],
576
+ ... theme=theme
577
+ ... )
578
+ >>> fig.show()
579
+ """
580
+ if 'factor_factor' not in interactions:
581
+ raise ChartNotAvailableError(
582
+ 'Factor × factor interaction not available.\nThis requires at least 2 factors in the analysis.',
583
+ chart='factor_factor_interaction',
584
+ available=list(interactions.keys()),
585
+ )
586
+
587
+ if len(factors) < 2:
588
+ raise ValidationError(f'Factor interaction requires at least 2 factors, got {len(factors)}.')
589
+
590
+ fi = interactions['factor_factor']
591
+ factor1, factor2 = factors[0], factors[1]
592
+
593
+ if factor1 not in fi.columns or factor2 not in fi.columns:
594
+ raise ValueError(f'Expected columns {factor1}, {factor2} in interaction data.\nFound: {list(fi.columns)}')
595
+
596
+ fig = go.Figure()
597
+
598
+ # Get unique levels, sorted for consistent ordering
599
+ levels1 = sorted(fi[factor1].unique(), key=str)
600
+ levels2 = sorted(fi[factor2].unique(), key=str)
601
+
602
+ # Color palette for factor2 levels
603
+ colors = [theme.data_color, theme.center_color] + _INTERACTION_COLORS
604
+
605
+ # Create one line per factor2 level
606
+ for i, level2 in enumerate(levels2):
607
+ mask = fi[factor2] == level2
608
+ subset = fi[mask].set_index(factor1)
609
+
610
+ x_values = [str(lv) for lv in levels1]
611
+ # Missing factor combinations are filled with 0: mathematically correct
612
+ # for interaction residuals (Rx), since absence = no interaction effect.
613
+ y_values = [subset.loc[lv, 'Rx'] if lv in subset.index else 0 for lv in levels1]
614
+
615
+ fig.add_trace(
616
+ go.Scatter(
617
+ x=x_values,
618
+ y=y_values,
619
+ mode='lines+markers',
620
+ name=str(level2),
621
+ line=dict(color=colors[i % len(colors)], width=2),
622
+ marker=dict(size=8, color=colors[i % len(colors)]),
623
+ hovertemplate=(f'{factor1}: %{{x}}<br>{factor2}: {level2}<br>Interaction: %{{y:.4f}}<extra></extra>'),
624
+ )
625
+ )
626
+
627
+ # Add horizontal reference line at 0
628
+ fig.add_hline(y=0, line_dash='dash', line_color='gray', line_width=1)
629
+
630
+ fig.update_layout(
631
+ title=f'Factor Interaction: {factor1} × {factor2}',
632
+ xaxis_title=factor1,
633
+ yaxis_title='Interaction Effect (Rx)',
634
+ width=width,
635
+ height=height,
636
+ legend_title=factor2,
637
+ hovermode='closest',
638
+ )
639
+
640
+ return fig
@@ -0,0 +1,78 @@
1
+ """Lane boundary rendering for control charts."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import TYPE_CHECKING
6
+
7
+ import plotly.graph_objects as go
8
+
9
+ if TYPE_CHECKING:
10
+ from .themes import ChartTheme
11
+
12
+
13
+ def add_lane_boundaries(
14
+ fig: go.Figure,
15
+ lane_boundaries: list[dict] | None,
16
+ y_range: tuple[float, float],
17
+ theme: ChartTheme,
18
+ row: int | None = None,
19
+ col: int | None = None,
20
+ show_labels: bool = True,
21
+ ) -> None:
22
+ """
23
+ Add vertical lane boundary lines to a chart figure.
24
+
25
+ Unified function for both single charts and faceted subplots.
26
+ Lane boundaries show where collapsed factors change within the chart,
27
+ helping distinguish groups of observations from different factor levels.
28
+
29
+ Parameters
30
+ ----------
31
+ fig : go.Figure
32
+ Plotly figure to add shapes to
33
+ lane_boundaries : list[dict] or None
34
+ List of boundary dicts with 'position' and 'label' keys
35
+ y_range : tuple[float, float]
36
+ (y_min, y_max) for vertical line extent
37
+ theme : ChartTheme
38
+ Theme with lane boundary styling
39
+ row : int, optional
40
+ Subplot row (1-indexed). None for single charts.
41
+ col : int, optional
42
+ Subplot column (1-indexed). None for single charts.
43
+ show_labels : bool, default True
44
+ Whether to show factor labels at boundary positions
45
+ """
46
+ if not lane_boundaries:
47
+ return
48
+
49
+ y_min, y_max = y_range
50
+
51
+ subplot_kwargs = {}
52
+ if row is not None and col is not None:
53
+ subplot_kwargs = {'row': row, 'col': col}
54
+
55
+ for boundary in lane_boundaries:
56
+ x_pos = boundary['position']
57
+ label = boundary.get('label', '')
58
+
59
+ fig.add_shape(
60
+ type='line',
61
+ x0=x_pos,
62
+ x1=x_pos,
63
+ y0=y_min,
64
+ y1=y_max,
65
+ line=dict(color=theme.lane_boundary_color, dash=theme.lane_boundary_dash, width=theme.lane_boundary_width),
66
+ **subplot_kwargs,
67
+ )
68
+
69
+ if show_labels and label:
70
+ fig.add_annotation(
71
+ x=x_pos,
72
+ y=y_max,
73
+ text=label,
74
+ showarrow=False,
75
+ yanchor='bottom',
76
+ font=dict(size=theme.lane_boundary_annotation_size, color=theme.lane_boundary_color),
77
+ **subplot_kwargs,
78
+ )