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
File without changes
@@ -0,0 +1,572 @@
1
+ """
2
+ VAS (Variance Analysis System) residual calculations for process behavior analysis.
3
+
4
+ This module calculates the Bishop VAS residuals (R1-R5) that decompose
5
+ total variation into interpretable components:
6
+
7
+ - R1: Total deviation from grand mean (pure algebra)
8
+ - R2: Within-cell (unexplained) variation (structure-dependent)
9
+ - R3: Interaction effects (factor × time) (pure algebra)
10
+ - R4: Time effects + unexplained (pure algebra given R2)
11
+ - R5: Factor effects + unexplained (pure algebra given R2)
12
+
13
+ R2 is the ONLY residual whose calculation varies by structure:
14
+ - exact (state 1): R2 = Y - Ȳ_kt (Eq 59), when all cells have n >= 2
15
+ - ma2 (states 2 & 3): R2 = (Y_j - Y_{j-1}) / 2 (Eq 13.8-13.9),
16
+ when any cell has n = 1 — applied to ALL observations across the
17
+ entire sorted stream, no grouping
18
+
19
+ All other residuals (R1, R3, R4, R5) are pure algebraic transformations
20
+ that don't depend on structure once the means are defined.
21
+
22
+ Module structure:
23
+ - Pure mean functions: calculate_grand_mean, calculate_factor_means,
24
+ calculate_time_means, calculate_cell_means
25
+ - Pure residual functions: calculate_r1_residual, calculate_r3_residual,
26
+ calculate_r4_residual, calculate_r5_residual
27
+ - Consolidated R2: calculate_r2(df, y, r2_method, n_per_cell)
28
+ - Orchestration: calculate_vas_residuals(df, spec, r2_method, ...)
29
+ - Request residuals: resolve_r6_groupby, calculate_r6_residuals — R6/RCR6 are
30
+ computed per execute() request from that request's by=, never stored on the
31
+ study-level frame (see spc_constants.REQUEST_RESIDUALS)
32
+ """
33
+
34
+ from __future__ import annotations
35
+
36
+ import logging
37
+ from collections.abc import Sequence
38
+ from typing import TYPE_CHECKING
39
+
40
+ import pandas as pd
41
+
42
+ from .exceptions import ValidationError
43
+ from .sds_detector import R2Method
44
+
45
+ if TYPE_CHECKING:
46
+ from .formulation_spec import FormulationSpec
47
+
48
+ logger = logging.getLogger(__name__)
49
+
50
+
51
+ # ============================================================================
52
+ # Pure Functions: Means (Ybar calculations)
53
+ # ============================================================================
54
+
55
+
56
+ def calculate_grand_mean(df: pd.DataFrame, response_var: str) -> float:
57
+ """
58
+ Calculate grand mean (Ȳ) - average of all observations.
59
+
60
+ This is the baseline for R1 residuals.
61
+
62
+ Parameters
63
+ ----------
64
+ df : DataFrame
65
+ Input data
66
+ response_var : str
67
+ Name of response variable column
68
+
69
+ Returns
70
+ -------
71
+ float
72
+ Grand mean (Ȳ)
73
+
74
+ Examples
75
+ --------
76
+ >>> df = pd.DataFrame({'weight': [10.0, 10.5, 9.5, 10.0]})
77
+ >>> calculate_grand_mean(df, 'weight')
78
+ 10.0
79
+ """
80
+ return df[response_var].mean()
81
+
82
+
83
+ def calculate_factor_means(df: pd.DataFrame, response_var: str, rsg_var_name: str) -> pd.Series:
84
+ """
85
+ Calculate factor-level means (Ȳ_k) - average for each factor level.
86
+
87
+ Broadcasts the mean for each factor level to all rows in that level.
88
+
89
+ Parameters
90
+ ----------
91
+ df : DataFrame
92
+ Input data
93
+ response_var : str
94
+ Name of response variable
95
+ rsg_var_name : str
96
+ Name of the composite rational subgroup column (e.g., 'rsg')
97
+
98
+ Returns
99
+ -------
100
+ Series
101
+ Factor means, same length as df (broadcast to rows)
102
+
103
+ Examples
104
+ --------
105
+ >>> df = pd.DataFrame({
106
+ ... 'lane': ['A', 'A', 'B', 'B'],
107
+ ... 'weight': [10.0, 10.5, 9.0, 9.5]
108
+ ... })
109
+ >>> calculate_factor_means(df, 'weight', 'lane')
110
+ 0 10.25
111
+ 1 10.25
112
+ 2 9.25
113
+ 3 9.25
114
+ Name: weight, dtype: float64
115
+ """
116
+ return df.groupby(rsg_var_name, observed=True)[response_var].transform('mean')
117
+
118
+
119
+ def calculate_time_means(df: pd.DataFrame, response_var: str, time_var: str) -> pd.Series:
120
+ """
121
+ Calculate time-level means (Ȳ_t) - average for each time point.
122
+
123
+ Broadcasts the mean for each time point to all rows at that time.
124
+
125
+ Parameters
126
+ ----------
127
+ df : DataFrame
128
+ Input data
129
+ response_var : str
130
+ Name of response variable
131
+ time_var : str
132
+ Name of time variable
133
+
134
+ Returns
135
+ -------
136
+ Series
137
+ Time means, same length as df (broadcast to rows)
138
+
139
+ Examples
140
+ --------
141
+ >>> df = pd.DataFrame({
142
+ ... 'pull': [1, 1, 2, 2],
143
+ ... 'weight': [10.0, 10.5, 9.0, 9.5]
144
+ ... })
145
+ >>> calculate_time_means(df, 'weight', 'pull')
146
+ 0 10.25
147
+ 1 10.25
148
+ 2 9.25
149
+ 3 9.25
150
+ Name: weight, dtype: float64
151
+ """
152
+ return df.groupby(time_var, observed=True)[response_var].transform('mean')
153
+
154
+
155
+ def calculate_cell_means(df: pd.DataFrame, response_var: str, rsg_var_name: str, time_var: str) -> pd.Series:
156
+ """
157
+ Calculate cell means (Ȳ_kt) - average for each (factor × time) cell.
158
+
159
+ Broadcasts the mean for each cell to all rows in that cell.
160
+
161
+ Parameters
162
+ ----------
163
+ df : DataFrame
164
+ Input data
165
+ response_var : str
166
+ Name of response variable
167
+ rsg_var_name : str
168
+ Name of the composite rational subgroup column (e.g., 'rsg')
169
+ time_var : str
170
+ Name of time variable
171
+
172
+ Returns
173
+ -------
174
+ Series
175
+ Cell means, same length as df (broadcast to rows)
176
+
177
+ Examples
178
+ --------
179
+ >>> df = pd.DataFrame({
180
+ ... 'lane': ['A', 'A', 'B', 'B'],
181
+ ... 'pull': [1, 1, 2, 2],
182
+ ... 'weight': [10.0, 10.5, 9.0, 9.5]
183
+ ... })
184
+ >>> calculate_cell_means(df, 'weight', 'lane', 'pull')
185
+ 0 10.25
186
+ 1 10.25
187
+ 2 9.25
188
+ 3 9.25
189
+ Name: weight, dtype: float64
190
+ """
191
+ return df.groupby([rsg_var_name, time_var], observed=True)[response_var].transform('mean')
192
+
193
+
194
+ # ============================================================================
195
+ # Pure Functions: Residuals (R1, R3, R4, R5)
196
+ # ============================================================================
197
+
198
+
199
+ def calculate_r1_residual(df: pd.DataFrame, response_var: str, grand_mean: float) -> pd.Series:
200
+ """
201
+ Calculate R1 residual: total deviation from grand mean.
202
+
203
+ R1 = Y - Ȳ (Bishop Equation 56)
204
+
205
+ R1 represents the total variation of each observation around the
206
+ overall average. It's the foundation for all other residuals.
207
+
208
+ Parameters
209
+ ----------
210
+ df : DataFrame
211
+ Input data
212
+ response_var : str
213
+ Name of response variable column
214
+ grand_mean : float
215
+ Grand mean (Ȳ)
216
+
217
+ Returns
218
+ -------
219
+ Series
220
+ R1 residuals, same length as df
221
+
222
+ Examples
223
+ --------
224
+ >>> df = pd.DataFrame({'weight': [10.1, 10.3, 9.9]})
225
+ >>> r1 = calculate_r1_residual(df, 'weight', 10.1)
226
+ >>> r1.tolist()
227
+ [0.0, 0.19999999999999929, -0.20000000000000107]
228
+
229
+ Notes
230
+ -----
231
+ R1 is the simplest residual - it just shows how far each observation
232
+ is from the overall average. The sum of all R1 values is always zero.
233
+ """
234
+ return df[response_var] - grand_mean
235
+
236
+
237
+ def calculate_r3_residual(
238
+ df: pd.DataFrame, response_var: str, factor_means: pd.Series, time_means: pd.Series, grand_mean: float
239
+ ) -> pd.Series:
240
+ """
241
+ Calculate R3 residual: interaction effects (factor × time).
242
+
243
+ R3 = Y - Ȳ_k - Ȳ_t + Ȳ (Bishop Equation 66)
244
+
245
+ R3 captures the interaction between factors and time. It represents
246
+ variation that can't be explained by factor effects or time effects alone.
247
+
248
+ Parameters
249
+ ----------
250
+ df : DataFrame
251
+ Input data
252
+ response_var : str
253
+ Name of response variable
254
+ factor_means : Series
255
+ Factor means (Ȳ_k)
256
+ time_means : Series
257
+ Time means (Ȳ_t)
258
+ grand_mean : float
259
+ Grand mean (Ȳ)
260
+
261
+ Returns
262
+ -------
263
+ Series
264
+ R3 residuals
265
+
266
+ Examples
267
+ --------
268
+ >>> df = pd.DataFrame({'y': [10.0, 10.5, 9.0, 9.5]})
269
+ >>> factor_means = pd.Series([10.25, 10.25, 9.25, 9.25])
270
+ >>> time_means = pd.Series([9.75, 9.75, 10.0, 10.0])
271
+ >>> r3 = calculate_r3_residual(df, 'y', factor_means, time_means, 9.875)
272
+ """
273
+ return df[response_var] - factor_means - time_means + grand_mean
274
+
275
+
276
+ def calculate_r4_residual(time_means: pd.Series, grand_mean: float, r2: pd.Series) -> pd.Series:
277
+ """
278
+ Calculate R4 residual: time effects + unexplained.
279
+
280
+ R4 = Ȳ_t - Ȳ + R2 (Bishop Equation 72)
281
+
282
+ R4 represents time effects plus within-cell variation. Used to
283
+ assess if time contributes meaningful variation.
284
+
285
+ Parameters
286
+ ----------
287
+ time_means : Series
288
+ Time means (Ȳ_t)
289
+ grand_mean : float
290
+ Grand mean (Ȳ)
291
+ r2 : Series
292
+ R2 residuals
293
+
294
+ Returns
295
+ -------
296
+ Series
297
+ R4 residuals
298
+ """
299
+ return time_means - grand_mean + r2
300
+
301
+
302
+ def calculate_r5_residual(factor_means: pd.Series, grand_mean: float, r2: pd.Series) -> pd.Series:
303
+ """
304
+ Calculate R5 residual: factor effects + unexplained.
305
+
306
+ R5 = Ȳ_k - Ȳ + R2 (Bishop Equation 75)
307
+
308
+ R5 represents factor effects plus within-cell variation. Used to
309
+ assess if factors contribute meaningful variation and to calculate
310
+ main effects.
311
+
312
+ Parameters
313
+ ----------
314
+ factor_means : Series
315
+ Factor means (Ȳ_k)
316
+ grand_mean : float
317
+ Grand mean (Ȳ)
318
+ r2 : Series
319
+ R2 residuals
320
+
321
+ Returns
322
+ -------
323
+ Series
324
+ R5 residuals
325
+ """
326
+ return factor_means - grand_mean + r2
327
+
328
+
329
+ # ============================================================================
330
+ # R2: Structure-dependent residual (consolidated)
331
+ # ============================================================================
332
+
333
+
334
+ def calculate_r2(df: pd.DataFrame, y: str, r2_method: R2Method, n_per_cell: pd.Series | None = None) -> pd.Series:
335
+ """
336
+ Calculate R2 residual using the specified method.
337
+
338
+ R2 is the ONLY structure-dependent residual. The method is determined by
339
+ observed cell sizes (via SDSRegistry.get_r2_method()):
340
+
341
+ - exact (state 1): R2 = Y - Ȳ_kt (Eq 59), all cells have n >= 2
342
+ - ma2 (states 2 & 3): R2 = (Y_j - Y_{j-1}) / 2 (Eq 13.8-13.9),
343
+ any cell has n = 1
344
+
345
+ When any cell has n=1, MA2 is applied to ALL observations across the
346
+ entire canonical-sorted stream — no grouping by rsg_key, no hybrid
347
+ per-cell selection. Bishop Eq 13.7-13.9 specify j=2,...,J with no
348
+ grouping; only j=1 gets R2=0.
349
+
350
+ Parameters
351
+ ----------
352
+ df : pd.DataFrame
353
+ Input data with 'cell_key', 'sort_key', 'Ybar_kt' columns
354
+ y : str
355
+ Name of response variable
356
+ r2_method : R2Method
357
+ 'exact', 'ma2', or 'hybrid' — retained for API compatibility.
358
+ Branching is driven by n_per_cell.
359
+ n_per_cell : pd.Series, optional
360
+ Pre-computed observations per cell. Pass from ADS to avoid recomputation.
361
+
362
+ Returns
363
+ -------
364
+ pd.Series
365
+ R2 residuals with name="R2"
366
+ """
367
+ if n_per_cell is None:
368
+ n_per_cell = df.groupby('cell_key', observed=True)[y].transform('size')
369
+
370
+ # State 1: all cells replicated → exact (Eq 59)
371
+ if (n_per_cell >= 2).all():
372
+ return pd.Series(df[y] - df['Ybar_kt'], index=df.index, name='R2')
373
+
374
+ # States 2 & 3: any singletons → MA2 for ALL observations (Eq 13.8-13.9)
375
+ # MA2 runs across the entire canonical-sorted stream — no grouping.
376
+ # j=1 has no predecessor → R2 is NaN (Bishop leaves it blank).
377
+ df_sorted = df.sort_values('sort_key')
378
+ y_sorted = df_sorted[y]
379
+ r2 = (y_sorted - y_sorted.shift(1)) / 2
380
+ return pd.Series(r2.loc[df.index], index=df.index, name='R2')
381
+
382
+
383
+ # ============================================================================
384
+ # Orchestration: calculate_vas_residuals
385
+ # ============================================================================
386
+
387
+
388
+ def _validate_prerequisites(df: pd.DataFrame) -> None:
389
+ """
390
+ Ensure required keys exist before computing residuals.
391
+
392
+ Parameters
393
+ ----------
394
+ df : pd.DataFrame
395
+ Input data
396
+
397
+ Raises
398
+ ------
399
+ ValueError
400
+ If required columns are missing
401
+ """
402
+ required = {'rsg_key', 'obs_id', 'cell_key'}
403
+ missing = required - set(df.columns)
404
+ if missing:
405
+ raise ValueError(
406
+ f'Missing required columns for VAS residuals: {missing}. Ensure data_preparation.build_keys() was called.'
407
+ )
408
+
409
+
410
+ def calculate_vas_residuals(
411
+ df: pd.DataFrame,
412
+ spec: FormulationSpec,
413
+ r2_method: R2Method,
414
+ n_per_cell: pd.Series | None = None,
415
+ ybar_kt: pd.Series | None = None,
416
+ ) -> pd.DataFrame:
417
+ """
418
+ Calculate all VAS residuals using structure-driven R2 method.
419
+
420
+ This is the primary entry point. The r2_method should be determined by
421
+ SDSRegistry.get_r2_method() based on observed cell sizes.
422
+
423
+ Parameters
424
+ ----------
425
+ df : pd.DataFrame
426
+ Input data (prepared, with keys: rsg_key, obs_id, cell_key)
427
+ spec : FormulationSpec
428
+ Analysis specification
429
+ r2_method : R2Method
430
+ 'exact', 'ma2', or 'hybrid' - determines R2 calculation
431
+ n_per_cell : pd.Series, optional
432
+ Pre-computed observations per cell. Pass from ADS to avoid recomputation.
433
+ ybar_kt : pd.Series, optional
434
+ Pre-computed cell means (Ȳ_kt). Pass from ADS to avoid recomputation.
435
+
436
+ Returns
437
+ -------
438
+ pd.DataFrame
439
+ Input data with added columns:
440
+ - Ybar, Ybar_k, Ybar_t, Ybar_kt (means)
441
+ - R1, R2, R3, R4, R5 (residuals)
442
+
443
+ Raises
444
+ ------
445
+ ValueError
446
+ If required columns are missing or no grouping structure
447
+ """
448
+ _validate_prerequisites(df)
449
+
450
+ if not spec.has_grouping:
451
+ raise ValueError('VAS residuals require grouping structure.\nNo grouping variables specified in spec.')
452
+
453
+ out = df.copy()
454
+ y = spec.response_var
455
+
456
+ # Step 1: Compute cell means FIRST (foundation for everything)
457
+ logger.debug('Calculating means (Ybar_kt, then unweighted Ybar, Ybar_k, Ybar_t)')
458
+ out['Ybar_kt'] = ybar_kt if ybar_kt is not None else calculate_cell_means(out, y, spec.rsg_var_name, spec.time_var)
459
+
460
+ # Step 2: Derive marginal means from cell means (unweighted means analysis)
461
+ # Bishop VAS uses mean of cell means, giving each experimental
462
+ # condition equal weight regardless of sample size within cells.
463
+ cell_means_unique = out.groupby([spec.rsg_var_name, spec.time_var], observed=True)['Ybar_kt'].first()
464
+
465
+ grand_mean = cell_means_unique.mean()
466
+ out['Ybar'] = grand_mean
467
+
468
+ factor_means = cell_means_unique.groupby(level=0, observed=True).mean()
469
+ out['Ybar_k'] = out[spec.rsg_var_name].map(factor_means).astype(float)
470
+
471
+ time_means = cell_means_unique.groupby(level=1, observed=True).mean()
472
+ out['Ybar_t'] = out[spec.time_var].map(time_means).astype(float)
473
+
474
+ # Step 3: Calculate R1 (pure algebra)
475
+ logger.debug('Calculating R1 residual')
476
+ out['R1'] = calculate_r1_residual(out, y, grand_mean)
477
+
478
+ # Step 4: Calculate R2 (structure-dependent)
479
+ logger.debug(f'Calculating R2 residual (method: {r2_method})')
480
+ out['R2'] = calculate_r2(out, y, r2_method, n_per_cell=n_per_cell)
481
+
482
+ # Step 5: Calculate R3 (interaction effects)
483
+ # Unified formula: Ybar_kt - Ybar_k - Ybar_t + Ybar + R2
484
+ # For exact (state 1): R2 = Y - Ybar_kt, so this simplifies to
485
+ # Y - Ybar_k - Ybar_t + Ybar (algebraically identical to old formula).
486
+ # For MA2 (states 2-3): adds R2 to each row per Bishop.
487
+ logger.debug('Calculating R3 residual')
488
+ out['R3'] = out['Ybar_kt'] - out['Ybar_k'] - out['Ybar_t'] + grand_mean + out['R2']
489
+
490
+ # Step 6: Calculate R4 (pure algebra given R2)
491
+ logger.debug('Calculating R4 residual')
492
+ out['R4'] = calculate_r4_residual(out['Ybar_t'], grand_mean, out['R2'])
493
+
494
+ # Step 7: Calculate R5 (pure algebra given R2)
495
+ logger.debug('Calculating R5 residual')
496
+ out['R5'] = calculate_r5_residual(out['Ybar_k'], grand_mean, out['R2'])
497
+
498
+ logger.debug('VAS residuals calculated successfully')
499
+ return out
500
+
501
+
502
+ # ============================================================================
503
+ # Request Residuals: R6 / RCR6 (computed per execute() request, never stored)
504
+ # ============================================================================
505
+
506
+
507
+ def resolve_r6_groupby(by: Sequence[str] | None, factors: Sequence[str]) -> str | list[str]:
508
+ """Resolve which factor(s) an R6 request groups on, or raise the reason it cannot.
509
+
510
+ One rule, two callers: ``Study._validate_execute_request`` calls this for its
511
+ ValidationErrors (validation stays pure — no frame is touched), and
512
+ ``Analysis._build_request_frame`` calls it again to compute. They cannot drift.
513
+
514
+ Parameters
515
+ ----------
516
+ by : Sequence[str] | None
517
+ The request's ``by=`` (list or ChartRequest tuple).
518
+ factors : Sequence[str]
519
+ The study's factors (``spec.rsg_vars_list``).
520
+
521
+ Returns
522
+ -------
523
+ str | list[str]
524
+ A single factor name, or the list of factors when ``by`` names several —
525
+ the exact shape ``DataFrame.groupby`` receives.
526
+ """
527
+ if not factors:
528
+ raise ValidationError('R6 requires factors. No factors defined in this study.')
529
+
530
+ # Determine which factor(s) from by
531
+ if by is not None:
532
+ by_factors = [b for b in by if b in factors]
533
+ if not by_factors:
534
+ raise ValidationError(
535
+ f'R6 requires at least one factor in by=.\n'
536
+ f'Available factors: {factors}\n'
537
+ f"Example: study.execute(chart='Xbar', value='R6', by=['{factors[0]}'])"
538
+ )
539
+ return by_factors if len(by_factors) > 1 else by_factors[0]
540
+ if len(factors) == 1:
541
+ return factors[0]
542
+ raise ValidationError(
543
+ f'R6 requires by=[factor(s)] to specify which factor(s).\n'
544
+ f'Available factors: {factors}\n'
545
+ f"Example: study.execute(chart='Xbar', value='R6', by=['{factors[0]}'])"
546
+ )
547
+
548
+
549
+ def calculate_r6_residuals(df: pd.DataFrame, groupby_key: str | list[str], recentered: bool) -> pd.DataFrame:
550
+ """Return a new frame carrying R6 (and RCR6 when recentered). Never mutates ``df``.
551
+
552
+ R6 = α_i + R2 where α_i = mean(R5 | factor level(s)).
553
+
554
+ Bit-identity constraints — this math moved verbatim from the old
555
+ ``Study._compute_r6`` and must keep producing identical floats:
556
+
557
+ - ``groupby`` keeps its default kwargs (no ``observed=``, no ``dropna=``) to
558
+ preserve row alignment and values exactly.
559
+ - RCR6 is ``Ybar + alpha + R2`` evaluated left-to-right. Do not rewrite it as
560
+ ``Ybar + R6``: float addition is non-associative, so the two differ in the
561
+ last ulp.
562
+
563
+ A recentered request carries **both** columns: the mR-of-RCR6 path reads the
564
+ plain R6 column (``_resolve_mr_source_column('RCR6') == 'R6'``).
565
+ """
566
+ df = df.copy()
567
+ alpha = df.groupby(groupby_key)['R5'].transform('mean')
568
+
569
+ df['R6'] = alpha + df['R2']
570
+ if recentered:
571
+ df['RCR6'] = df['Ybar'] + alpha + df['R2']
572
+ return df