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,603 @@
1
+ """
2
+ Effects and interactions calculator for VAS (Variance Analysis System).
3
+
4
+ This module calculates:
5
+ - Main effects for each factor (mean of R5 per level)
6
+ - Time effects (mean of R1 per time point)
7
+ - Interaction effects (factor × time, factor × factor)
8
+ - Main effect scores (R2 + Main Effect for each factor)
9
+
10
+ These calculations help identify which factors and time periods contribute
11
+ most to process variation.
12
+
13
+ Follows the Pythonic Hadley philosophy:
14
+ - Pure functions where possible
15
+ - Clear, descriptive names
16
+ - Comprehensive documentation
17
+ - Type hints everywhere
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import logging
23
+ from typing import TYPE_CHECKING
24
+
25
+ import pandas as pd
26
+
27
+ from .exceptions import ValidationError
28
+
29
+ if TYPE_CHECKING:
30
+ from .formulation_spec import FormulationSpec
31
+
32
+ logger = logging.getLogger(__name__)
33
+
34
+
35
+ # ============================================================================
36
+ # Pure Functions: Main Effects
37
+ # ============================================================================
38
+
39
+
40
+ def calculate_factor_main_effects(df: pd.DataFrame, factor: str) -> pd.DataFrame:
41
+ """
42
+ Calculate main effect for a single factor.
43
+
44
+ Main Effect = mean(R5) per factor level
45
+
46
+ The main effect shows the average impact of each factor level on
47
+ the response, after accounting for within-cell variation.
48
+
49
+ Parameters
50
+ ----------
51
+ df : DataFrame
52
+ Data with R5 column
53
+ factor : str
54
+ Factor column name
55
+
56
+ Returns
57
+ -------
58
+ DataFrame
59
+ Two columns: [factor, 'Main_Effect']
60
+ One row per factor level
61
+
62
+ Examples
63
+ --------
64
+ >>> df = pd.DataFrame({
65
+ ... 'lane': ['A', 'A', 'B', 'B'],
66
+ ... 'R5': [0.2, 0.3, -0.2, -0.3]
67
+ ... })
68
+ >>> me = calculate_factor_main_effects(df, 'lane')
69
+ >>> me
70
+ lane Main_Effect
71
+ 0 A 0.25
72
+ 1 B -0.25
73
+
74
+ Notes
75
+ -----
76
+ Large positive main effects indicate factor levels that consistently
77
+ produce higher values. Large negative effects indicate lower values.
78
+ """
79
+ if 'R5' not in df.columns:
80
+ raise ValidationError(
81
+ f'Cannot calculate main effects - R5 column missing.\n'
82
+ f'Available columns: {df.columns.tolist()}\n'
83
+ f'Fix: Calculate VAS residuals first'
84
+ )
85
+
86
+ if factor not in df.columns:
87
+ raise ValidationError(f"Factor '{factor}' not found in data.\nAvailable columns: {df.columns.tolist()}")
88
+
89
+ me = df.groupby(factor, sort=False, observed=True)['R5'].mean().rename('Main_Effect').reset_index()
90
+
91
+ # Validate uniqueness
92
+ if me.duplicated(subset=[factor]).any():
93
+ raise ValidationError(f"Duplicate levels found for factor '{factor}'")
94
+
95
+ return me[[factor, 'Main_Effect']]
96
+
97
+
98
+ def calculate_time_main_effects(df: pd.DataFrame, time_var: str) -> pd.DataFrame:
99
+ """
100
+ Calculate main effect for time.
101
+
102
+ Time Effect = mean(R4) per time point
103
+
104
+ R4 = Ȳ_t - Ȳ + R2 preserves the time effect plus within-cell noise,
105
+ so mean(R4) per time point gives the time main effect (analogous to
106
+ mean(R5) per factor level for factor main effects).
107
+
108
+ Parameters
109
+ ----------
110
+ df : DataFrame
111
+ Data with R4 column
112
+ time_var : str
113
+ Time variable name
114
+
115
+ Returns
116
+ -------
117
+ DataFrame
118
+ Two columns: [time_var, 'PT_ME']
119
+ One row per time point
120
+
121
+ Examples
122
+ --------
123
+ >>> df = pd.DataFrame({
124
+ ... 'pull': [1, 1, 2, 2],
125
+ ... 'R4': [0.1, 0.2, -0.1, -0.2]
126
+ ... })
127
+ >>> te = calculate_time_main_effects(df, 'pull')
128
+ >>> te
129
+ pull PT_ME
130
+ 0 1 0.15
131
+ 1 2 -0.15
132
+ """
133
+ if 'R4' not in df.columns:
134
+ raise ValidationError('Cannot calculate time effects - R4 column missing.\nCalculate VAS residuals first.')
135
+
136
+ te = df.groupby(time_var, sort=False, observed=True)['R4'].mean().rename('PT_ME').reset_index()
137
+
138
+ return te[[time_var, 'PT_ME']]
139
+
140
+
141
+ def calculate_main_effect_scores(df: pd.DataFrame, factor: str, main_effects: pd.DataFrame) -> pd.DataFrame:
142
+ """
143
+ Calculate main effect scores: R2 + Main Effect per row.
144
+
145
+ MEs = R2 + Main_Effect(factor_level)
146
+
147
+ This combines within-cell variation (R2) with the factor's main
148
+ effect to show each observation's contribution to factor variation.
149
+
150
+ Parameters
151
+ ----------
152
+ df : DataFrame
153
+ Data with R2 column and factor column
154
+ factor : str
155
+ Factor name
156
+ main_effects : DataFrame
157
+ Main effects from calculate_factor_main_effects()
158
+ Must have columns: [factor, 'Main_Effect']
159
+
160
+ Returns
161
+ -------
162
+ DataFrame
163
+ Two columns: [factor, '{factor}_MEs']
164
+ Same number of rows as df
165
+
166
+ Examples
167
+ --------
168
+ >>> df = pd.DataFrame({
169
+ ... 'lane': ['A', 'A', 'B'],
170
+ ... 'R2': [0.1, -0.1, 0.05]
171
+ ... })
172
+ >>> me = pd.DataFrame({
173
+ ... 'lane': ['A', 'B'],
174
+ ... 'Main_Effect': [0.25, -0.25]
175
+ ... })
176
+ >>> mes = calculate_main_effect_scores(df, 'lane', me)
177
+ >>> mes
178
+ lane lane_MEs
179
+ 0 A 0.35
180
+ 1 A 0.15
181
+ 2 B -0.20
182
+ """
183
+ if 'R2' not in df.columns:
184
+ raise ValidationError('Cannot calculate MEs - R2 column missing')
185
+
186
+ if factor not in df.columns:
187
+ raise ValidationError(f"Factor '{factor}' not in data")
188
+
189
+ if not isinstance(main_effects, pd.DataFrame):
190
+ raise TypeError('main_effects must be a DataFrame')
191
+
192
+ required_cols = {factor, 'Main_Effect'}
193
+ if not required_cols.issubset(main_effects.columns):
194
+ raise ValidationError(
195
+ f'main_effects missing required columns.\nRequired: {required_cols}\nFound: {set(main_effects.columns)}'
196
+ )
197
+
198
+ # Merge to add Main_Effect column
199
+ merged = df[[factor, 'R2']].merge(main_effects, on=factor, how='left', validate='many_to_one')
200
+
201
+ # Calculate score
202
+ label = f'{factor}_MEs'
203
+ merged[label] = merged['R2'] + merged['Main_Effect']
204
+
205
+ return merged[[factor, label]]
206
+
207
+
208
+ # ============================================================================
209
+ # Pure Functions: Interactions
210
+ # ============================================================================
211
+
212
+
213
+ def calculate_interaction_cell_means(df: pd.DataFrame, rsg_vars: list[str], time_var: str) -> pd.Series:
214
+ """
215
+ Calculate cell-level interaction effect (factor × time).
216
+
217
+ Interaction = mean(R3) per cell
218
+
219
+ For SDS 1 (full replication), the cell-average of R3 equals the
220
+ interaction effect: Ȳ_kt - Ȳ_k - Ȳ_t + Ȳ
221
+
222
+ Parameters
223
+ ----------
224
+ df : DataFrame
225
+ Data with R3 column
226
+ rsg_vars : list of str
227
+ Factor variable names
228
+ time_var : str
229
+ Time variable name
230
+
231
+ Returns
232
+ -------
233
+ Series
234
+ Interaction effects broadcast to all rows
235
+ Length equals length of input DataFrame
236
+ Each row gets the mean R3 value for its factor×time cell
237
+
238
+ Examples
239
+ --------
240
+ >>> df = pd.DataFrame({
241
+ ... 'lane': ['A', 'A', 'B', 'B'],
242
+ ... 'pull': [1, 1, 2, 2],
243
+ ... 'R3': [0.1, 0.15, -0.1, -0.05]
244
+ ... })
245
+ >>> int_eff = calculate_interaction_cell_means(df, ['lane'], 'pull')
246
+ >>> int_eff # Broadcast to all rows
247
+ 0 0.125
248
+ 1 0.125
249
+ 2 -0.075
250
+ 3 -0.075
251
+ Name: R3, dtype: float64
252
+ """
253
+ if 'R3' not in df.columns:
254
+ raise ValidationError('Cannot calculate interactions - R3 column missing')
255
+
256
+ keys = list(rsg_vars) + [time_var]
257
+
258
+ # Use transform to broadcast cell means back to all rows
259
+ return df.groupby(keys, sort=False, observed=True)['R3'].transform('mean')
260
+
261
+
262
+ def calculate_pdc_by_time_sds2(
263
+ df: pd.DataFrame, ybar_kt: pd.Series, ybar_k: pd.Series, ybar_t: pd.Series, ybar: float
264
+ ) -> pd.Series:
265
+ """
266
+ Calculate process disruption component (PDC) for SDS 2.
267
+
268
+ PDC = Ȳ_kt - Ȳ_k - Ȳ_t + Ȳ
269
+
270
+ For SDS 2 (no replication), we calculate this directly from means
271
+ rather than from R3 averages.
272
+
273
+ Parameters
274
+ ----------
275
+ df : DataFrame
276
+ Input data (for index/length)
277
+ ybar_kt : Series
278
+ Cell means
279
+ ybar_k : Series
280
+ Factor means
281
+ ybar_t : Series
282
+ Time means
283
+ ybar : float
284
+ Grand mean
285
+
286
+ Returns
287
+ -------
288
+ Series
289
+ PDC values per row
290
+ """
291
+ return ybar_kt - ybar_k - ybar_t + ybar
292
+
293
+
294
+ def calculate_factor_interaction_effects(
295
+ df: pd.DataFrame, rsg_vars: list[str], main_effects: dict[str, pd.DataFrame]
296
+ ) -> pd.DataFrame:
297
+ """
298
+ Calculate two-factor interaction effects (F1 × F2).
299
+
300
+ Rx = mean(R5)_ij - ME_i - ME_j
301
+
302
+ Where:
303
+ - mean(R5)_ij is the average R5 for factor combination i,j
304
+ - ME_i is the main effect of factor 1 level i
305
+ - ME_j is the main effect of factor 2 level j
306
+
307
+ Parameters
308
+ ----------
309
+ df : DataFrame
310
+ Data with R5 column and factor columns
311
+ rsg_vars : list of str
312
+ Factor names (at least 2 required)
313
+ main_effects : dict
314
+ Dict mapping factor name to its main effects DataFrame
315
+
316
+ Returns
317
+ -------
318
+ DataFrame
319
+ Columns: [factor1, factor2, 'Rx']
320
+
321
+ Notes
322
+ -----
323
+ Only calculates for first 2 factors if more than 2 provided.
324
+ Higher-order interactions (3+) are not currently supported.
325
+
326
+ Examples
327
+ --------
328
+ >>> # With lanes A,B and heads 1,2
329
+ >>> # If lane A adds 0.2, head 1 adds 0.1, but A×1 adds 0.4 total
330
+ >>> # Then Rx = 0.4 - 0.2 - 0.1 = 0.1 (synergistic interaction)
331
+ """
332
+ if len(rsg_vars) < 2:
333
+ logger.debug(f'Only {len(rsg_vars)} factor(s) - factor interaction requires at least 2. Skipping.')
334
+ return pd.DataFrame()
335
+
336
+ if 'R5' not in df.columns:
337
+ raise ValidationError('Cannot calculate interactions - R5 column missing')
338
+
339
+ # Use first 2 factors only
340
+ if len(rsg_vars) > 2:
341
+ logger.warning(f'More than 2 factors in RSG - calculating interaction for first 2: {rsg_vars[:2]}')
342
+
343
+ factor1, factor2 = rsg_vars[0], rsg_vars[1]
344
+
345
+ # Calculate mean R5 for each factor combination
346
+ rsg_r5 = df.groupby(list(rsg_vars[:2]), as_index=False, observed=True)['R5'].mean()
347
+
348
+ # Add main effects for each factor
349
+ for _i, factor in enumerate([factor1, factor2]):
350
+ me = main_effects.get(factor)
351
+ if me is None or not isinstance(me, pd.DataFrame):
352
+ raise ValidationError(
353
+ f"Main effects for '{factor}' not found or invalid.\nAvailable: {list(main_effects.keys())}"
354
+ )
355
+
356
+ # Validate structure
357
+ if not {factor, 'Main_Effect'}.issubset(me.columns):
358
+ raise ValidationError(f"Main effects for '{factor}' missing required columns")
359
+
360
+ rsg_r5 = rsg_r5.merge(me, on=factor, how='left', validate='many_to_one')
361
+ # Rename to distinguish
362
+ rsg_r5 = rsg_r5.rename(columns={'Main_Effect': f'ME_{factor}'})
363
+
364
+ # Calculate interaction residual
365
+ rsg_r5['Rx'] = rsg_r5['R5'] - rsg_r5[f'ME_{factor1}'] - rsg_r5[f'ME_{factor2}']
366
+
367
+ return rsg_r5[[factor1, factor2, 'Rx']]
368
+
369
+
370
+ def calculate_factor_interaction_scores(
371
+ df: pd.DataFrame, rsg_vars: list[str], factor_interactions: pd.DataFrame
372
+ ) -> pd.DataFrame:
373
+ """
374
+ Calculate factor interaction scores per row.
375
+
376
+ FIE = R2 + Rx
377
+
378
+ Where Rx is the two-factor interaction effect.
379
+
380
+ Parameters
381
+ ----------
382
+ df : DataFrame
383
+ Data with R2 column
384
+ rsg_vars : list of str
385
+ Factor names (first 2 used)
386
+ factor_interactions : DataFrame
387
+ From calculate_factor_interaction_effects()
388
+ Must have columns: [factor1, factor2, 'Rx']
389
+
390
+ Returns
391
+ -------
392
+ DataFrame
393
+ Columns: [factor1, factor2, 'factor_interaction_effects']
394
+ """
395
+ if factor_interactions.empty:
396
+ return pd.DataFrame()
397
+
398
+ if 'R2' not in df.columns:
399
+ raise ValidationError('Cannot calculate interaction scores - R2 missing')
400
+
401
+ factor1, factor2 = rsg_vars[0], rsg_vars[1]
402
+
403
+ # Merge to add Rx to each row
404
+ merged = df[[factor1, factor2, 'R2']].merge(
405
+ factor_interactions, on=[factor1, factor2], how='left', validate='many_to_one'
406
+ )
407
+
408
+ merged['factor_interaction_effects'] = merged['R2'] + merged['Rx']
409
+
410
+ return merged[[factor1, factor2, 'factor_interaction_effects']]
411
+
412
+
413
+ # ============================================================================
414
+ # Orchestration Class
415
+ # ============================================================================
416
+
417
+
418
+ class EffectsCalculator:
419
+ """
420
+ Calculates main effects and interactions from VAS residuals.
421
+
422
+ This class coordinates calculation of:
423
+ - Main effects (factor-level averages)
424
+ - Time effects (time-point averages)
425
+ - Main effect scores (per-row contributions)
426
+ - Interaction effects (factor × time, factor × factor)
427
+
428
+ Requires VAS residuals (R1, R2, R5) to be present in data.
429
+
430
+ Examples
431
+ --------
432
+ Calculate all effects:
433
+
434
+ >>> calc = EffectsCalculator()
435
+ >>> effects_dict = calc.calculate_all_effects(df, spec)
436
+ >>> effects_dict.keys()
437
+ dict_keys(['main_effect', 'lane', 'head', 'time', 'lane_MEs', ...])
438
+ """
439
+
440
+ def calculate_all_effects(self, df: pd.DataFrame, spec: FormulationSpec) -> dict:
441
+ """
442
+ Calculate all main effects.
443
+
444
+ Returns a dictionary containing:
445
+ - 'main_effect': Main effects at RSG level (backward compatibility)
446
+ - '{factor}': Main effects for each factor
447
+ - 'time': Time main effects (if time variable specified)
448
+ - '{factor}_MEs': Main effect scores for each factor
449
+
450
+ Note: Interactions (factor_time, factor_factor) are calculated
451
+ separately via calculate_interactions().
452
+
453
+ Parameters
454
+ ----------
455
+ df : DataFrame
456
+ Data with VAS residuals (R1, R2, R5)
457
+ spec : FormulationSpec
458
+ Analysis specification
459
+
460
+ Returns
461
+ -------
462
+ dict
463
+ Dictionary of effects DataFrames
464
+
465
+ Examples
466
+ --------
467
+ >>> calc = EffectsCalculator()
468
+ >>> effects = calc.calculate_all_effects(df, spec)
469
+ >>> effects['lane'] # Main effects for lane factor
470
+ lane Main_Effect
471
+ 0 A 0.25
472
+ 1 B -0.25
473
+ >>> effects['time'] # Time main effects
474
+ pull PT_ME
475
+ 0 1 0.15
476
+ 1 2 -0.15
477
+ """
478
+ effects = {}
479
+
480
+ if not spec.rsg_vars:
481
+ logger.debug('No grouping variables - skipping effects calculation')
482
+ return effects
483
+
484
+ # Validate residuals present
485
+ self._validate_residuals(df)
486
+
487
+ # Calculate main effects for each factor
488
+ logger.debug('Calculating factor main effects')
489
+ for factor in spec.rsg_vars:
490
+ me = calculate_factor_main_effects(df, factor)
491
+ effects[factor] = me
492
+
493
+ # Calculate RSG-level main effect (for backward compatibility)
494
+ if spec.rsg_var_name:
495
+ rsg_me = calculate_factor_main_effects(df, spec.rsg_var_name)
496
+ effects['main_effect'] = rsg_me
497
+
498
+ # Calculate time main effects
499
+ if spec.has_time:
500
+ logger.debug('Calculating time main effects')
501
+ te = calculate_time_main_effects(df, spec.time_var)
502
+ effects['time'] = te
503
+
504
+ # Calculate main effect scores for each factor
505
+ logger.debug('Calculating main effect scores')
506
+ for factor in spec.rsg_vars:
507
+ me = effects[factor]
508
+ mes = calculate_main_effect_scores(df, factor, me)
509
+ effects[f'{factor}_MEs'] = mes
510
+
511
+ # Calculate factor interaction effects (if 2+ factors)
512
+ # Note: factor × factor interaction is stored in interactions dict, not effects
513
+ if len(spec.rsg_vars) >= 2:
514
+ logger.debug('Calculating factor interaction effects')
515
+ fi = calculate_factor_interaction_effects(df, spec.rsg_vars, effects)
516
+ if not fi.empty:
517
+ # Calculate per-row scores (kept in effects for backward compatibility)
518
+ fie = calculate_factor_interaction_scores(df, spec.rsg_vars, fi)
519
+ if not fie.empty:
520
+ effects['factor_interaction_effects'] = fie
521
+
522
+ logger.debug('All effects calculated successfully')
523
+ return effects
524
+
525
+ def calculate_interactions(
526
+ self, df: pd.DataFrame, spec: FormulationSpec, sds: int, effects: dict | None = None
527
+ ) -> dict:
528
+ """
529
+ Calculate interaction effects.
530
+
531
+ Calculates two types of interactions:
532
+ - factor_time: Factor × time interaction (PDC - Process Disruption Component)
533
+ - factor_factor: Factor × factor interaction (if 2+ factors)
534
+
535
+ Parameters
536
+ ----------
537
+ df : DataFrame
538
+ Data with VAS residuals
539
+ spec : FormulationSpec
540
+ Analysis specification
541
+ sds : int
542
+ Sampling Design State
543
+ effects : dict, optional
544
+ Effects dict from calculate_all_effects() (needed for factor_factor)
545
+
546
+ Returns
547
+ -------
548
+ dict
549
+ Dictionary with:
550
+ - 'factor_time': Factor × time interaction (PDC)
551
+ - 'factor_factor': Factor × factor interaction (if 2+ factors)
552
+ """
553
+ interactions = {}
554
+
555
+ # Factor × time interaction (requires grouping + time)
556
+ if spec.has_grouping and spec.has_time:
557
+ if 'R3' not in df.columns:
558
+ logger.warning('R3 not found - cannot calculate factor × time interaction')
559
+ else:
560
+ logger.debug('Calculating factor × time interaction (PDC)')
561
+
562
+ if sds == 1:
563
+ # Full replication: use cell averages of R3
564
+ pdc = calculate_interaction_cell_means(df, spec.rsg_vars, spec.time_var)
565
+ interactions['factor_time'] = pdc
566
+
567
+ elif sds == 2:
568
+ # No replication: direct calculation
569
+ pdc = calculate_pdc_by_time_sds2(
570
+ df,
571
+ df['Ybar_kt'],
572
+ df['Ybar_k'],
573
+ df['Ybar_t'],
574
+ df['Ybar'].iloc[0], # Grand mean (constant)
575
+ )
576
+ interactions['factor_time'] = pdc
577
+
578
+ else:
579
+ # SDS 3+: use SDS 1 approach
580
+ logger.debug(f'SDS {sds}: Using cell-average approach for PDC')
581
+ pdc = calculate_interaction_cell_means(df, spec.rsg_vars, spec.time_var)
582
+ interactions['factor_time'] = pdc
583
+
584
+ # Factor × factor interaction (requires 2+ factors)
585
+ if spec.rsg_vars and len(spec.rsg_vars) >= 2 and effects is not None:
586
+ logger.debug('Calculating factor × factor interaction')
587
+ fi = calculate_factor_interaction_effects(df, spec.rsg_vars, effects)
588
+ if not fi.empty:
589
+ interactions['factor_factor'] = fi
590
+
591
+ return interactions
592
+
593
+ def _validate_residuals(self, df: pd.DataFrame) -> None:
594
+ """Validate that required residuals are present."""
595
+ required = {'R1', 'R2', 'R5'}
596
+ missing = required - set(df.columns)
597
+
598
+ if missing:
599
+ raise ValidationError(
600
+ f'Cannot calculate effects - missing residuals: {missing}\n'
601
+ f'Available columns: {df.columns.tolist()}\n'
602
+ f'Fix: Calculate VAS residuals before calling calculate_all_effects()'
603
+ )