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,1711 @@
1
+ """
2
+ Synthetic Data Generators for Statistical Process Control
3
+
4
+ This module provides data generators for all Sampling Design States (SDS)
5
+ defined in the Variance Analysis System framework by Dr. Donald Wheeler
6
+ and extended by Dr. Thomas A. Bishop.
7
+
8
+ Purpose:
9
+ --------
10
+ - Testing: Validate analysis algorithms with known ground truth
11
+ - Documentation: Provide reproducible examples
12
+ - Education: Demonstrate different data structures and their implications
13
+ - Development: Rapid prototyping of new features
14
+
15
+ Quick Start:
16
+ -----------
17
+ >>> from processbehavior.datasets import synthetic
18
+ >>>
19
+ >>> # Generate SDS1 data (full replication)
20
+ >>> df = synthetic.make_sds1(K1=3, K2=2, T=8, n_min=2, n_max=4)
21
+ >>>
22
+ >>> # Use with analysis
23
+ >>> from processbehavior.analysis import Analysis
24
+ >>> spec = {'analysis_type': 'Xbar', 'rsg_vars': ['factor 1'],
25
+ ... 'response_var': 'y', 'time_var': 'time'}
26
+ >>> results = Analysis(df, spec).calculate()
27
+
28
+ Sampling Design States:
29
+ ----------------------
30
+ - SDS 1: Full replication (n≥2 in all cells)
31
+ - SDS 2: No replication (n=1 in all cells)
32
+ - SDS 3: Partial replication (mixed n=1 and n≥2)
33
+ - SDS 4: Single condition over time (one factor, multiple time points)
34
+ - SDS 5: Nested design (hierarchical factors, asynchronous)
35
+ - SDS 6: Unstructured/regime changes (irregular patterns)
36
+
37
+ References:
38
+ -----------
39
+ Wheeler, D. J. (1995). Advanced Topics in Statistical Process Control.
40
+ SPC Press, Knoxville, TN.
41
+
42
+ Bishop, D. R. (2023). Personal communication and collaboration on
43
+ Variance Analysis System implementation.
44
+ """
45
+
46
+ import logging
47
+
48
+ import numpy as np
49
+ import pandas as pd
50
+
51
+ from ..exceptions import ValidationError
52
+ from ..formulation_spec import FormulationSpec
53
+
54
+ logger = logging.getLogger(__name__)
55
+
56
+ __all__ = [
57
+ 'make_sds1',
58
+ 'make_sds2',
59
+ 'make_sds3',
60
+ 'make_sds4',
61
+ 'make_sds5',
62
+ 'make_sds6',
63
+ 'make_design',
64
+ 'make_edge_cases',
65
+ 'make_large_dataset',
66
+ ]
67
+
68
+
69
+ # ============================================================================
70
+ # SDS 1: Full Replication - Most Statistically Powerful
71
+ # ============================================================================
72
+
73
+
74
+ def make_sds1(
75
+ K1: int = 3,
76
+ K2: int = 2,
77
+ T: int = 8,
78
+ n_min: int = 2,
79
+ n_max: int = 5,
80
+ mu: float = 50.0,
81
+ sigma: float = 0.4,
82
+ factor1_effect_size: float = 2.0,
83
+ factor2_effect_size: float = 1.5,
84
+ time_effect_size: float = 1.0,
85
+ interaction_effect_size: float = 0.5,
86
+ seed: int | None = None,
87
+ include_truth: bool = False,
88
+ factor1_names: list[str] | None = None,
89
+ factor2_names: list[str] | None = None,
90
+ ) -> pd.DataFrame:
91
+ """
92
+ Generate Sampling Design State 1 data (full replication).
93
+
94
+ SDS1 is the gold standard for process studies - every combination of
95
+ factors and time has multiple observations, allowing true estimation of
96
+ within-cell variance. This is what designed experiments strive for.
97
+
98
+ Characteristics:
99
+ ---------------
100
+ - Every (factor1, factor2, time) cell has n ≥ 2 observations
101
+ - True within-cell variance can be estimated directly
102
+ - Supports full interaction analysis (R3 calculations)
103
+ - Most statistically powerful design
104
+ - Enables calculation of all residuals (R1-R5) with precision
105
+
106
+ Use Cases:
107
+ ---------
108
+ - Testing Xbar-S chart algorithms
109
+ - Validating variance decomposition (R1-R5)
110
+ - Demonstrating interaction effects
111
+ - Teaching full-factorial designs
112
+ - Baseline for comparing other SDS types
113
+
114
+ Args:
115
+ K1: Number of factor 1 levels (e.g., machines, lanes)
116
+ K2: Number of factor 2 levels (e.g., operators, shifts)
117
+ T: Number of time periods (e.g., days, shifts, batches)
118
+ n_min: Minimum observations per cell
119
+ n_max: Maximum observations per cell
120
+ mu: Grand mean (process center)
121
+ sigma: Within-cell standard deviation (pure error)
122
+ factor1_effect_size: Standard deviation of factor 1 main effects
123
+ factor2_effect_size: Standard deviation of factor 2 main effects
124
+ time_effect_size: Standard deviation of time main effects
125
+ interaction_effect_size: Standard deviation of interactions
126
+ seed: Random seed for reproducibility (None = random)
127
+ include_truth: If True, include true effects as columns for validation
128
+ factor1_names: Custom factor 1 level names (default: F1_1, F1_2, ...)
129
+ factor2_names: Custom factor 2 level names (default: F2_1, F2_2, ...)
130
+
131
+ Returns:
132
+ DataFrame with columns:
133
+ - time: Time period (1 to T)
134
+ - factor 1: Factor 1 level
135
+ - factor 2: Factor 2 level
136
+ - y: Response variable
137
+
138
+ Optional columns (if include_truth=True):
139
+ - true_factor1_effect: The ρ_i component
140
+ - true_factor2_effect: The φ_j component
141
+ - true_time_effect: The τ_t component
142
+ - true_interaction: The combined interaction component
143
+ - true_error: The ε component (pure error)
144
+
145
+ Mathematical Model:
146
+ Y_ijtr = μ + ρ_i + φ_j + τ_t + (ρφ)_ij + (ρτ)_it + (φτ)_jt + ε_ijtr
147
+
148
+ where:
149
+ μ: Grand mean (mu parameter)
150
+ ρ_i: Factor 1 main effect ~ N(0, factor1_effect_size²)
151
+ φ_j: Factor 2 main effect ~ N(0, factor2_effect_size²)
152
+ τ_t: Time main effect ~ N(0, time_effect_size²)
153
+ interactions: Combined effect ~ N(0, interaction_effect_size²)
154
+ ε_ijtr: Pure error for replicate r ~ N(0, σ²)
155
+
156
+ Examples:
157
+ >>> # Basic usage - 3 factor1 levels × 2 factor2 levels × 8 time periods
158
+ >>> df = make_sds1(K1=3, K2=2, T=8, seed=42)
159
+ >>> print(f"Generated {len(df)} observations")
160
+ >>> df.groupby(['factor 1', 'factor 2', 'time']).size().min()
161
+ 2 # All cells have at least 2 observations
162
+
163
+ >>> # With ground truth for validation testing
164
+ >>> df = make_sds1(K1=2, K2=2, T=4, include_truth=True, seed=42)
165
+
166
+ >>> # Custom factor names
167
+ >>> df = make_sds1(K1=3, K2=2, T=6,
168
+ ... factor1_names=['Machine_A', 'Machine_B', 'Machine_C'],
169
+ ... factor2_names=['Operator_1', 'Operator_2'],
170
+ ... seed=42)
171
+
172
+ Validation:
173
+ The function automatically validates that generated data meets SDS1 criteria:
174
+ - All (K1 × K2 × T) cells present (no missing combinations)
175
+ - All cells have n_min ≤ n ≤ n_max observations
176
+ - Response variable is numeric
177
+ - No missing values in critical columns
178
+
179
+ See Also:
180
+ make_sds2: For unreplicated designs (n=1 per cell)
181
+ make_sds3: For partial replication (mixed n=1 and n≥2)
182
+ make_sds4: For time series (single factor over time)
183
+ """
184
+ rng = np.random.default_rng(seed)
185
+
186
+ # Generate true effects from the model
187
+ rho = rng.normal(0, factor1_effect_size, K1) # Factor 1 main effects
188
+ phi = rng.normal(0, factor2_effect_size, K2) # Factor 2 main effects
189
+ tau = rng.normal(0, time_effect_size, T) # Time main effects
190
+ inter = rng.normal(0, interaction_effect_size, (K1, K2, T)) # Interactions
191
+
192
+ # Use custom factor names if provided
193
+ if factor1_names is None:
194
+ factor1_names = [f'F1_{k + 1}' for k in range(K1)]
195
+ elif len(factor1_names) != K1:
196
+ raise ValidationError(f'Length of factor1_names ({len(factor1_names)}) must equal K1 ({K1})')
197
+
198
+ if factor2_names is None:
199
+ factor2_names = [f'F2_{k + 1}' for k in range(K2)]
200
+ elif len(factor2_names) != K2:
201
+ raise ValidationError(f'Length of factor2_names ({len(factor2_names)}) must equal K2 ({K2})')
202
+
203
+ rows = []
204
+ for k1 in range(K1):
205
+ for k2 in range(K2):
206
+ for t in range(T):
207
+ # Random number of observations per cell (uniform distribution)
208
+ n = rng.integers(n_min, n_max + 1)
209
+
210
+ for _i in range(n):
211
+ # Generate observation from true model
212
+ epsilon = rng.normal(0, sigma) # Pure error
213
+ y = mu + rho[k1] + phi[k2] + tau[t] + inter[k1, k2, t] + epsilon
214
+
215
+ row = {'time': t + 1, 'factor 1': factor1_names[k1], 'factor 2': factor2_names[k2], 'y': y}
216
+
217
+ # Optionally include ground truth for validation
218
+ if include_truth:
219
+ row.update(
220
+ {
221
+ 'true_factor1_effect': rho[k1],
222
+ 'true_factor2_effect': phi[k2],
223
+ 'true_time_effect': tau[t],
224
+ 'true_interaction': inter[k1, k2, t],
225
+ 'true_error': epsilon,
226
+ 'true_mean': mu + rho[k1] + phi[k2] + tau[t] + inter[k1, k2, t],
227
+ }
228
+ )
229
+
230
+ rows.append(row)
231
+
232
+ df = pd.DataFrame(rows)
233
+
234
+ # Validation - ensure SDS1 criteria met
235
+ cell_counts = df.groupby(['factor 1', 'factor 2', 'time']).size()
236
+
237
+ if cell_counts.min() < n_min:
238
+ raise ValidationError(f'SDS1 validation failed: min n={cell_counts.min()} < {n_min}')
239
+ if cell_counts.max() > n_max:
240
+ raise ValidationError(f'SDS1 validation failed: max n={cell_counts.max()} > {n_max}')
241
+ expected_cells = K1 * K2 * T
242
+ if len(cell_counts) != expected_cells:
243
+ raise ValidationError(
244
+ f'SDS1 validation failed: expected {expected_cells} cells, got {len(cell_counts)} '
245
+ '(some factor-time combinations missing)'
246
+ )
247
+
248
+ logger.debug(
249
+ f'Generated SDS1 data: {K1} × {K2} factors × {T} times × ~{(n_min + n_max) / 2:.1f} reps/cell '
250
+ f'= {len(df)} observations'
251
+ )
252
+
253
+ return df
254
+
255
+
256
+ # ============================================================================
257
+ # SDS 2: No Replication - Common in Designed Experiments
258
+ # ============================================================================
259
+
260
+
261
+ def make_sds2(
262
+ K1: int = 3,
263
+ K2: int = 2,
264
+ T: int = 10,
265
+ mu: float = 50.0,
266
+ sigma: float = 0.4,
267
+ factor1_effect_size: float = 2.0,
268
+ factor2_effect_size: float = 1.5,
269
+ time_effect_size: float = 1.2,
270
+ interaction_effect_size: float = 0.6,
271
+ seed: int | None = None,
272
+ include_truth: bool = False,
273
+ factor1_names: list[str] | None = None,
274
+ factor2_names: list[str] | None = None,
275
+ ) -> pd.DataFrame:
276
+ """
277
+ Generate Sampling Design State 2 data (no replication).
278
+
279
+ SDS2 is the classic unreplicated factorial design - exactly one observation
280
+ per cell. Common in designed experiments where resources are limited.
281
+
282
+ Characteristics:
283
+ ---------------
284
+ - Exactly one observation per (factor1, factor2, time) cell
285
+ - Within-cell variance must be estimated indirectly (moving average)
286
+ - Cannot separate pure error from interaction effects
287
+ - More efficient data collection, less statistical power
288
+
289
+ Use Cases:
290
+ ---------
291
+ - Testing moving average R2 calculation algorithms
292
+ - Validating SDS detection logic
293
+ - Demonstrating unreplicated factorial designs
294
+ - Cost-constrained or time-constrained data collection
295
+
296
+ Args:
297
+ K1: Number of factor 1 levels
298
+ K2: Number of factor 2 levels
299
+ T: Number of time periods
300
+ mu: Grand mean
301
+ sigma: Process standard deviation (confounded with interaction)
302
+ factor1_effect_size: Std dev of factor 1 effects
303
+ factor2_effect_size: Std dev of factor 2 effects
304
+ time_effect_size: Std dev of time effects
305
+ interaction_effect_size: Std dev of interactions
306
+ seed: Random seed for reproducibility
307
+ include_truth: Include true effects as columns
308
+ factor1_names: Custom factor 1 level names
309
+ factor2_names: Custom factor 2 level names
310
+
311
+ Returns:
312
+ DataFrame with exactly K1 × K2 × T observations
313
+
314
+ Examples:
315
+ >>> # Basic usage
316
+ >>> df = make_sds2(K1=3, K2=2, T=8, seed=42)
317
+ >>> df.groupby(['factor 1', 'factor 2', 'time']).size().max()
318
+ 1 # All cells have exactly 1 observation
319
+ >>> len(df)
320
+ 48 # K1 × K2 × T = 3 × 2 × 8
321
+
322
+ Validation:
323
+ - Exactly n=1 per cell (all cells)
324
+ - Total observations = K1 × K2 × T
325
+ - No missing factor-time combinations
326
+
327
+ See Also:
328
+ make_sds1: For replicated designs
329
+ make_sds3: For mixed replication (most realistic)
330
+ """
331
+ rng = np.random.default_rng(seed)
332
+
333
+ rho = rng.normal(0, factor1_effect_size, K1)
334
+ phi = rng.normal(0, factor2_effect_size, K2)
335
+ tau = rng.normal(0, time_effect_size, T)
336
+ inter = rng.normal(0, interaction_effect_size, (K1, K2, T))
337
+
338
+ if factor1_names is None:
339
+ factor1_names = [f'F1_{k + 1}' for k in range(K1)]
340
+ elif len(factor1_names) != K1:
341
+ raise ValidationError('Length of factor1_names must equal K1')
342
+
343
+ if factor2_names is None:
344
+ factor2_names = [f'F2_{k + 1}' for k in range(K2)]
345
+ elif len(factor2_names) != K2:
346
+ raise ValidationError('Length of factor2_names must equal K2')
347
+
348
+ rows = []
349
+ for k1 in range(K1):
350
+ for k2 in range(K2):
351
+ for t in range(T):
352
+ # Single observation per cell - this is the defining characteristic
353
+ epsilon = rng.normal(0, sigma)
354
+ y = mu + rho[k1] + phi[k2] + tau[t] + inter[k1, k2, t] + epsilon
355
+
356
+ row = {'time': t + 1, 'factor 1': factor1_names[k1], 'factor 2': factor2_names[k2], 'y': y}
357
+
358
+ if include_truth:
359
+ row.update(
360
+ {
361
+ 'true_factor1_effect': rho[k1],
362
+ 'true_factor2_effect': phi[k2],
363
+ 'true_time_effect': tau[t],
364
+ 'true_interaction': inter[k1, k2, t],
365
+ 'true_confounded': inter[k1, k2, t] + epsilon,
366
+ }
367
+ )
368
+
369
+ rows.append(row)
370
+
371
+ df = pd.DataFrame(rows)
372
+
373
+ # Validation
374
+ cell_counts = df.groupby(['factor 1', 'factor 2', 'time']).size()
375
+
376
+ if not (cell_counts == 1).all():
377
+ raise ValidationError(
378
+ f'SDS2 validation failed: expected all n=1, got min={cell_counts.min()}, max={cell_counts.max()}'
379
+ )
380
+
381
+ expected_n = K1 * K2 * T
382
+ if len(df) != expected_n:
383
+ raise ValidationError(f'SDS2 validation failed: expected {expected_n} obs, got {len(df)}')
384
+
385
+ logger.debug(f'Generated SDS2 data: {K1} × {K2} factors × {T} times = {len(df)} observations')
386
+
387
+ return df
388
+
389
+
390
+ # ============================================================================
391
+ # SDS 3: Partial Replication - MOST COMMON IN PRACTICE!
392
+ # ============================================================================
393
+
394
+
395
+ def make_sds3( # noqa: C901
396
+ K1: int = 3,
397
+ K2: int = 2,
398
+ T: int = 8,
399
+ p_replicated: float = 0.5,
400
+ n_when_replicated: int = 3,
401
+ mu: float = 50.0,
402
+ sigma: float = 0.5,
403
+ factor1_effect_size: float = 2.0,
404
+ factor2_effect_size: float = 1.5,
405
+ time_effect_size: float = 1.0,
406
+ interaction_effect_size: float = 0.5,
407
+ seed: int | None = None,
408
+ replication_pattern: str = 'random',
409
+ include_truth: bool = False,
410
+ factor1_names: list[str] | None = None,
411
+ factor2_names: list[str] | None = None,
412
+ ) -> pd.DataFrame:
413
+ """
414
+ Generate Sampling Design State 3 data (partial replication).
415
+
416
+ SDS3 represents reality: some cells have replication, others don't.
417
+ This is the MOST COMMON real-world scenario and the HARDEST to analyze.
418
+
419
+ Characteristics:
420
+ ---------------
421
+ - Some cells have n=1, others have n≥2
422
+ - Requires hybrid R2 estimation
423
+ - Reflects real-world data collection reality
424
+ - Most challenging to analyze correctly
425
+
426
+ Use Cases:
427
+ ---------
428
+ - Testing hybrid variance estimation algorithms
429
+ - Demonstrating real-world data structures
430
+ - Validating robustness to missing data
431
+ - Development of SDS detection logic
432
+
433
+ Args:
434
+ K1: Number of factor 1 levels
435
+ K2: Number of factor 2 levels
436
+ T: Number of time periods
437
+ p_replicated: Proportion of cells with replication (0 to 1)
438
+ n_when_replicated: Number of observations in replicated cells
439
+ mu: Grand mean
440
+ sigma: Within-cell standard deviation
441
+ factor1_effect_size: Std dev of factor 1 effects
442
+ factor2_effect_size: Std dev of factor 2 effects
443
+ time_effect_size: Std dev of time effects
444
+ interaction_effect_size: Std dev of interactions
445
+ seed: Random seed
446
+ replication_pattern: How to assign replication:
447
+ - 'random': Random cells replicated (default)
448
+ - 'early_times': First p_replicated fraction of time periods
449
+ - 'late_times': Last p_replicated fraction of time periods
450
+ - 'checkerboard': Alternating pattern
451
+ - 'corners': Extreme combinations get replication
452
+ include_truth: Include true effects as columns
453
+ factor1_names: Custom factor 1 level names
454
+ factor2_names: Custom factor 2 level names
455
+
456
+ Returns:
457
+ DataFrame with mixed replication structure, including 'cell_type' column
458
+
459
+ Examples:
460
+ >>> # 50% of cells replicated (balanced)
461
+ >>> df = make_sds3(K1=3, K2=2, T=8, p_replicated=0.5, seed=42)
462
+ >>> cell_sizes = df.groupby(['factor 1', 'factor 2', 'time']).size()
463
+ >>> print(f"Singles: {(cell_sizes == 1).sum()}, "
464
+ ... f"Replicated: {(cell_sizes > 1).sum()}")
465
+
466
+ Validation:
467
+ - At least one cell with n=1
468
+ - At least one cell with n≥2
469
+ - Actual replication proportion within ±25% of target
470
+ - All factor-time combinations present
471
+
472
+ See Also:
473
+ make_sds1: For fully replicated designs
474
+ make_sds2: For unreplicated designs
475
+ """
476
+ rng = np.random.default_rng(seed)
477
+
478
+ # Generate true effects
479
+ rho = rng.normal(0, factor1_effect_size, K1)
480
+ phi = rng.normal(0, factor2_effect_size, K2)
481
+ tau = rng.normal(0, time_effect_size, T)
482
+ inter = rng.normal(0, interaction_effect_size, (K1, K2, T))
483
+
484
+ if factor1_names is None:
485
+ factor1_names = [f'F1_{k + 1}' for k in range(K1)]
486
+ elif len(factor1_names) != K1:
487
+ raise ValidationError('Length of factor1_names must equal K1')
488
+
489
+ if factor2_names is None:
490
+ factor2_names = [f'F2_{k + 1}' for k in range(K2)]
491
+ elif len(factor2_names) != K2:
492
+ raise ValidationError('Length of factor2_names must equal K2')
493
+
494
+ rows = []
495
+ replicated_cells = []
496
+ unreplicated_cells = []
497
+ total_cells = K1 * K2 * T
498
+
499
+ for k1 in range(K1):
500
+ for k2 in range(K2):
501
+ for t in range(T):
502
+ # Determine replication based on pattern
503
+ if replication_pattern == 'random':
504
+ is_replicated = rng.random() < p_replicated
505
+
506
+ elif replication_pattern == 'early_times':
507
+ is_replicated = t < int(T * p_replicated)
508
+
509
+ elif replication_pattern == 'late_times':
510
+ is_replicated = t >= int(T * (1 - p_replicated))
511
+
512
+ elif replication_pattern == 'checkerboard':
513
+ is_replicated = (k1 + k2 + t) % 2 == 0
514
+
515
+ elif replication_pattern == 'corners':
516
+ is_corner = (k1 == 0 or k1 == K1 - 1) or (t == 0 or t == T - 1)
517
+ is_replicated = is_corner or (rng.random() < p_replicated / 2)
518
+
519
+ else:
520
+ raise ValidationError(
521
+ f'Unknown replication pattern: {replication_pattern}. '
522
+ f"Valid: 'random', 'early_times', 'late_times', "
523
+ f"'checkerboard', 'corners'"
524
+ )
525
+
526
+ # Determine n for this cell
527
+ n = n_when_replicated if is_replicated else 1
528
+
529
+ # Track cell types for validation
530
+ if is_replicated:
531
+ replicated_cells.append((k1, k2, t))
532
+ else:
533
+ unreplicated_cells.append((k1, k2, t))
534
+
535
+ # Generate observations
536
+ for _i in range(n):
537
+ epsilon = rng.normal(0, sigma)
538
+ y = mu + rho[k1] + phi[k2] + tau[t] + inter[k1, k2, t] + epsilon
539
+
540
+ row = {
541
+ 'time': t + 1,
542
+ 'factor 1': factor1_names[k1],
543
+ 'factor 2': factor2_names[k2],
544
+ 'y': y,
545
+ 'cell_type': 'replicated' if is_replicated else 'unreplicated',
546
+ }
547
+
548
+ if include_truth:
549
+ row.update(
550
+ {
551
+ 'true_factor1_effect': rho[k1],
552
+ 'true_factor2_effect': phi[k2],
553
+ 'true_time_effect': tau[t],
554
+ 'true_interaction': inter[k1, k2, t],
555
+ 'true_error': epsilon,
556
+ }
557
+ )
558
+
559
+ rows.append(row)
560
+
561
+ df = pd.DataFrame(rows)
562
+
563
+ # Validation - ensure true SDS3 (mixed replication)
564
+ cell_counts = df.groupby(['factor 1', 'factor 2', 'time']).size()
565
+ has_singles = (cell_counts == 1).any()
566
+ has_multiples = (cell_counts >= 2).any()
567
+
568
+ if not (has_singles and has_multiples):
569
+ raise ValidationError(
570
+ f'SDS3 validation failed: must have both single and multiple observations. '
571
+ f'Got singles={has_singles}, multiples={has_multiples}. '
572
+ f'Try adjusting p_replicated or replication_pattern.'
573
+ )
574
+
575
+ # Check replication proportion
576
+ actual_p = len(replicated_cells) / total_cells
577
+ if abs(actual_p - p_replicated) > 0.25: # Allow 25% tolerance
578
+ logger.warning(f'SDS3: target replication {p_replicated:.1%}, actual {actual_p:.1%} (outside 25% tolerance)')
579
+
580
+ logger.debug(
581
+ f'Generated SDS3 data: {len(df)} obs, '
582
+ f'{len(replicated_cells)} replicated cells ({actual_p:.1%}), '
583
+ f'{len(unreplicated_cells)} unreplicated cells'
584
+ )
585
+
586
+ return df
587
+
588
+
589
+ # ============================================================================
590
+ # SDS 4: Incomplete grid, occupied cells replicated (N >= 2)
591
+ # ============================================================================
592
+
593
+
594
+ def make_sds4(
595
+ K1: int = 3,
596
+ K2: int = 2,
597
+ T: int = 6,
598
+ p_drop: float = 0.25,
599
+ n_min: int = 2,
600
+ n_max: int = 5,
601
+ mu: float = 50.0,
602
+ sigma: float = 0.4,
603
+ factor1_effect_size: float = 2.0,
604
+ factor2_effect_size: float = 1.5,
605
+ time_effect_size: float = 1.0,
606
+ seed: int | None = None,
607
+ factor1_names: list[str] | None = None,
608
+ factor2_names: list[str] | None = None,
609
+ ) -> pd.DataFrame:
610
+ """
611
+ Generate Observed Design State 4 (ODS 4) data.
612
+
613
+ ODS 4 is Bishop's classification for incomplete designs where every
614
+ occupied (factor × time) cell carries at least 2 observations. Some
615
+ cells are missing entirely (no data was collected there). After NA
616
+ filtering during tidying, ODS 4 collapses to ADS 1 (full replication
617
+ on the surviving grid).
618
+
619
+ Characteristics
620
+ ---------------
621
+ - K1 × K2 × T grid with a fraction of cells empty (no observations)
622
+ - Every occupied cell has n_min..n_max observations (n_min ≥ 2)
623
+ - Bishop's structural ODS 4 classification by the N_kt distribution
624
+
625
+ Args:
626
+ K1, K2: Number of levels for factor 1 and factor 2
627
+ T: Number of time periods
628
+ p_drop: Approximate fraction of cells to leave empty (0 < p_drop < 1).
629
+ The exact number is ``max(1, int(p_drop * K1 * K2 * T))``, clipped
630
+ so at least one cell remains occupied.
631
+ n_min, n_max: Replication range for occupied cells. ``n_min`` must be
632
+ at least 2 for the result to classify as ODS 4 (not ODS 6).
633
+ mu, sigma: Grand mean and within-cell error standard deviation
634
+ factor1_effect_size, factor2_effect_size, time_effect_size: Standard
635
+ deviations of the corresponding main effects
636
+ seed: Random seed for reproducibility
637
+ factor1_names, factor2_names: Custom factor level names
638
+
639
+ Returns:
640
+ DataFrame with columns ``time``, ``factor 1``, ``factor 2``, ``y``.
641
+ Empty cells appear as single rows with ``y = NaN`` so the SDS
642
+ detector observes them as N_kt = 0.
643
+
644
+ Examples:
645
+ >>> df = make_sds4(K1=3, K2=2, T=6, seed=42)
646
+ >>> # After formulate(), study.observed_design_state.sds == 4
647
+ >>> # After tidy/NA drop, study.analytical_design_state.sds == 1
648
+ """
649
+ if not 0 < p_drop < 1:
650
+ raise ValidationError(f'p_drop must be in (0, 1), got {p_drop}')
651
+ if n_min < 2:
652
+ raise ValidationError(f'ODS 4 requires n_min >= 2 (no singletons), got {n_min}')
653
+ if n_max < n_min:
654
+ raise ValidationError(f'n_max ({n_max}) must be >= n_min ({n_min})')
655
+
656
+ rng = np.random.default_rng(seed)
657
+
658
+ rho = rng.normal(0, factor1_effect_size, K1)
659
+ phi = rng.normal(0, factor2_effect_size, K2)
660
+ tau = rng.normal(0, time_effect_size, T)
661
+
662
+ if factor1_names is None:
663
+ factor1_names = [f'F1_{k + 1}' for k in range(K1)]
664
+ elif len(factor1_names) != K1:
665
+ raise ValidationError(f'Length of factor1_names ({len(factor1_names)}) must equal K1 ({K1})')
666
+ if factor2_names is None:
667
+ factor2_names = [f'F2_{k + 1}' for k in range(K2)]
668
+ elif len(factor2_names) != K2:
669
+ raise ValidationError(f'Length of factor2_names ({len(factor2_names)}) must equal K2 ({K2})')
670
+
671
+ total_cells = K1 * K2 * T
672
+ n_drop = max(1, int(p_drop * total_cells))
673
+ n_drop = min(n_drop, total_cells - 1)
674
+ dropped = set(rng.choice(total_cells, size=n_drop, replace=False).tolist())
675
+
676
+ rows = []
677
+ cell_idx = 0
678
+ for k1 in range(K1):
679
+ for k2 in range(K2):
680
+ for t in range(T):
681
+ if cell_idx in dropped:
682
+ rows.append(
683
+ {
684
+ 'time': t + 1,
685
+ 'factor 1': factor1_names[k1],
686
+ 'factor 2': factor2_names[k2],
687
+ 'y': np.nan,
688
+ }
689
+ )
690
+ else:
691
+ n = int(rng.integers(n_min, n_max + 1))
692
+ for _ in range(n):
693
+ eps = rng.normal(0, sigma)
694
+ y = mu + rho[k1] + phi[k2] + tau[t] + eps
695
+ rows.append(
696
+ {
697
+ 'time': t + 1,
698
+ 'factor 1': factor1_names[k1],
699
+ 'factor 2': factor2_names[k2],
700
+ 'y': y,
701
+ }
702
+ )
703
+ cell_idx += 1
704
+
705
+ df = pd.DataFrame(rows)
706
+
707
+ logger.debug(
708
+ f'Generated ODS 4 data: {K1}x{K2}x{T} grid, {n_drop}/{total_cells} cells empty, '
709
+ f'{len(df)} total rows ({df["y"].notna().sum()} valid responses)'
710
+ )
711
+ return df
712
+
713
+
714
+ # ============================================================================
715
+ # SDS 5: Incomplete grid, occupied cells unreplicated (N = 1)
716
+ # ============================================================================
717
+
718
+
719
+ def make_sds5(
720
+ K1: int = 3,
721
+ K2: int = 2,
722
+ T: int = 6,
723
+ p_drop: float = 0.25,
724
+ mu: float = 50.0,
725
+ sigma: float = 0.4,
726
+ factor1_effect_size: float = 2.0,
727
+ factor2_effect_size: float = 1.5,
728
+ time_effect_size: float = 1.0,
729
+ seed: int | None = None,
730
+ factor1_names: list[str] | None = None,
731
+ factor2_names: list[str] | None = None,
732
+ ) -> pd.DataFrame:
733
+ """
734
+ Generate Observed Design State 5 (ODS 5) data.
735
+
736
+ ODS 5 is Bishop's classification for incomplete designs where every
737
+ occupied (factor × time) cell carries exactly one observation. Some
738
+ cells are missing entirely. After NA filtering during tidying, ODS 5
739
+ collapses to ADS 2 (no replication on the surviving grid).
740
+
741
+ Characteristics
742
+ ---------------
743
+ - K1 × K2 × T grid with a fraction of cells empty
744
+ - Every occupied cell has exactly one observation
745
+ - Bishop's structural ODS 5 classification by the N_kt distribution
746
+
747
+ Args:
748
+ K1, K2: Number of levels for factor 1 and factor 2
749
+ T: Number of time periods
750
+ p_drop: Approximate fraction of cells to leave empty (0 < p_drop < 1).
751
+ The exact number is ``max(1, int(p_drop * K1 * K2 * T))``, clipped
752
+ so at least one cell remains occupied.
753
+ mu, sigma: Grand mean and within-cell error standard deviation
754
+ factor1_effect_size, factor2_effect_size, time_effect_size: Standard
755
+ deviations of the corresponding main effects
756
+ seed: Random seed for reproducibility
757
+ factor1_names, factor2_names: Custom factor level names
758
+
759
+ Returns:
760
+ DataFrame with columns ``time``, ``factor 1``, ``factor 2``, ``y``.
761
+ Empty cells appear as single rows with ``y = NaN``.
762
+
763
+ Examples:
764
+ >>> df = make_sds5(K1=3, K2=2, T=6, seed=42)
765
+ >>> # After formulate(), study.observed_design_state.sds == 5
766
+ >>> # After tidy/NA drop, study.analytical_design_state.sds == 2
767
+ """
768
+ if not 0 < p_drop < 1:
769
+ raise ValidationError(f'p_drop must be in (0, 1), got {p_drop}')
770
+
771
+ rng = np.random.default_rng(seed)
772
+
773
+ rho = rng.normal(0, factor1_effect_size, K1)
774
+ phi = rng.normal(0, factor2_effect_size, K2)
775
+ tau = rng.normal(0, time_effect_size, T)
776
+
777
+ if factor1_names is None:
778
+ factor1_names = [f'F1_{k + 1}' for k in range(K1)]
779
+ elif len(factor1_names) != K1:
780
+ raise ValidationError(f'Length of factor1_names ({len(factor1_names)}) must equal K1 ({K1})')
781
+ if factor2_names is None:
782
+ factor2_names = [f'F2_{k + 1}' for k in range(K2)]
783
+ elif len(factor2_names) != K2:
784
+ raise ValidationError(f'Length of factor2_names ({len(factor2_names)}) must equal K2 ({K2})')
785
+
786
+ total_cells = K1 * K2 * T
787
+ n_drop = max(1, int(p_drop * total_cells))
788
+ n_drop = min(n_drop, total_cells - 1)
789
+ dropped = set(rng.choice(total_cells, size=n_drop, replace=False).tolist())
790
+
791
+ rows = []
792
+ cell_idx = 0
793
+ for k1 in range(K1):
794
+ for k2 in range(K2):
795
+ for t in range(T):
796
+ if cell_idx in dropped:
797
+ rows.append(
798
+ {
799
+ 'time': t + 1,
800
+ 'factor 1': factor1_names[k1],
801
+ 'factor 2': factor2_names[k2],
802
+ 'y': np.nan,
803
+ }
804
+ )
805
+ else:
806
+ eps = rng.normal(0, sigma)
807
+ y = mu + rho[k1] + phi[k2] + tau[t] + eps
808
+ rows.append(
809
+ {
810
+ 'time': t + 1,
811
+ 'factor 1': factor1_names[k1],
812
+ 'factor 2': factor2_names[k2],
813
+ 'y': y,
814
+ }
815
+ )
816
+ cell_idx += 1
817
+
818
+ df = pd.DataFrame(rows)
819
+
820
+ logger.debug(
821
+ f'Generated ODS 5 data: {K1}x{K2}x{T} grid, {n_drop}/{total_cells} cells empty, '
822
+ f'every occupied cell has N=1, {df["y"].notna().sum()} valid responses'
823
+ )
824
+ return df
825
+
826
+
827
+ # ============================================================================
828
+ # SDS 6: Incomplete grid, mixed replication (some N=1, some N>=2)
829
+ # ============================================================================
830
+
831
+
832
+ def make_sds6( # noqa: C901
833
+ K1: int = 3,
834
+ K2: int = 2,
835
+ T: int = 6,
836
+ p_drop: float = 0.25,
837
+ p_singleton: float = 0.4,
838
+ n_min: int = 2,
839
+ n_max: int = 5,
840
+ mu: float = 50.0,
841
+ sigma: float = 0.4,
842
+ factor1_effect_size: float = 2.0,
843
+ factor2_effect_size: float = 1.5,
844
+ time_effect_size: float = 1.0,
845
+ seed: int | None = None,
846
+ factor1_names: list[str] | None = None,
847
+ factor2_names: list[str] | None = None,
848
+ ) -> pd.DataFrame:
849
+ """
850
+ Generate Observed Design State 6 (ODS 6) data.
851
+
852
+ ODS 6 is Bishop's classification for incomplete designs where occupied
853
+ cells carry a mix of singleton (N=1) and replicated (N>=2) observations.
854
+ Some cells are missing entirely. After NA filtering during tidying,
855
+ ODS 6 collapses to ADS 3 (mixed replication on the surviving grid).
856
+
857
+ Characteristics
858
+ ---------------
859
+ - K1 × K2 × T grid with a fraction of cells empty
860
+ - Among occupied cells, some have exactly 1 observation; others have
861
+ ``n_min..n_max`` observations
862
+ - Bishop's structural ODS 6 classification by the N_kt distribution
863
+ (must have BOTH singletons AND replicated cells)
864
+
865
+ Args:
866
+ K1, K2: Number of levels for factor 1 and factor 2
867
+ T: Number of time periods
868
+ p_drop: Approximate fraction of cells to leave empty (0 < p_drop < 1)
869
+ p_singleton: Among occupied cells, probability of singleton vs
870
+ replicated (0 < p_singleton < 1). The generator guarantees at
871
+ least one of each so the result classifies as ODS 6, not ODS 4/5.
872
+ n_min, n_max: Replication range for the replicated cells (>= 2)
873
+ mu, sigma: Grand mean and within-cell error standard deviation
874
+ factor1_effect_size, factor2_effect_size, time_effect_size: Standard
875
+ deviations of the corresponding main effects
876
+ seed: Random seed for reproducibility
877
+ factor1_names, factor2_names: Custom factor level names
878
+
879
+ Returns:
880
+ DataFrame with columns ``time``, ``factor 1``, ``factor 2``, ``y``.
881
+ Empty cells appear as single rows with ``y = NaN``.
882
+
883
+ Examples:
884
+ >>> df = make_sds6(K1=3, K2=2, T=6, seed=42)
885
+ >>> # After formulate(), study.observed_design_state.sds == 6
886
+ >>> # After tidy/NA drop, study.analytical_design_state.sds == 3
887
+ """
888
+ if not 0 < p_drop < 1:
889
+ raise ValidationError(f'p_drop must be in (0, 1), got {p_drop}')
890
+ if not 0 < p_singleton < 1:
891
+ raise ValidationError(f'p_singleton must be in (0, 1), got {p_singleton}')
892
+ if n_min < 2:
893
+ raise ValidationError(f'n_min must be >= 2 (replicated cells), got {n_min}')
894
+ if n_max < n_min:
895
+ raise ValidationError(f'n_max ({n_max}) must be >= n_min ({n_min})')
896
+
897
+ rng = np.random.default_rng(seed)
898
+
899
+ rho = rng.normal(0, factor1_effect_size, K1)
900
+ phi = rng.normal(0, factor2_effect_size, K2)
901
+ tau = rng.normal(0, time_effect_size, T)
902
+
903
+ if factor1_names is None:
904
+ factor1_names = [f'F1_{k + 1}' for k in range(K1)]
905
+ elif len(factor1_names) != K1:
906
+ raise ValidationError(f'Length of factor1_names ({len(factor1_names)}) must equal K1 ({K1})')
907
+ if factor2_names is None:
908
+ factor2_names = [f'F2_{k + 1}' for k in range(K2)]
909
+ elif len(factor2_names) != K2:
910
+ raise ValidationError(f'Length of factor2_names ({len(factor2_names)}) must equal K2 ({K2})')
911
+
912
+ total_cells = K1 * K2 * T
913
+ n_drop = max(1, int(p_drop * total_cells))
914
+ n_drop = min(n_drop, total_cells - 2) # Need at least 2 occupied cells (1 singleton, 1 replicated)
915
+ if n_drop < 1:
916
+ raise ValidationError(
917
+ f"Grid too small for ODS 6: K1*K2*T={total_cells} can't fit "
918
+ f'both empty cells and at least one singleton + one replicated cell'
919
+ )
920
+
921
+ occupied_indices = [
922
+ i for i in range(total_cells) if i not in set(rng.choice(total_cells, size=n_drop, replace=False).tolist())
923
+ ]
924
+ # rebuild dropped set from indices not in occupied
925
+ occupied_set = set(occupied_indices)
926
+ dropped_set = set(range(total_cells)) - occupied_set
927
+ n_occupied = len(occupied_indices)
928
+
929
+ # Assign singleton vs replicated status to each occupied cell;
930
+ # guarantee at least one of each.
931
+ singleton_flags = rng.random(n_occupied) < p_singleton
932
+ if not singleton_flags.any():
933
+ singleton_flags[0] = True
934
+ if singleton_flags.all():
935
+ singleton_flags[-1] = False
936
+
937
+ occupied_to_singleton = dict(zip(occupied_indices, singleton_flags.tolist(), strict=False))
938
+
939
+ rows = []
940
+ cell_idx = 0
941
+ for k1 in range(K1):
942
+ for k2 in range(K2):
943
+ for t in range(T):
944
+ if cell_idx in dropped_set:
945
+ rows.append(
946
+ {
947
+ 'time': t + 1,
948
+ 'factor 1': factor1_names[k1],
949
+ 'factor 2': factor2_names[k2],
950
+ 'y': np.nan,
951
+ }
952
+ )
953
+ else:
954
+ n = 1 if occupied_to_singleton[cell_idx] else int(rng.integers(n_min, n_max + 1))
955
+ for _ in range(n):
956
+ eps = rng.normal(0, sigma)
957
+ y = mu + rho[k1] + phi[k2] + tau[t] + eps
958
+ rows.append(
959
+ {
960
+ 'time': t + 1,
961
+ 'factor 1': factor1_names[k1],
962
+ 'factor 2': factor2_names[k2],
963
+ 'y': y,
964
+ }
965
+ )
966
+ cell_idx += 1
967
+
968
+ df = pd.DataFrame(rows)
969
+
970
+ logger.debug(
971
+ f'Generated ODS 6 data: {K1}x{K2}x{T} grid, {n_drop}/{total_cells} cells empty, '
972
+ f'{int(singleton_flags.sum())} singletons + '
973
+ f'{int((~singleton_flags).sum())} replicated cells, '
974
+ f'{df["y"].notna().sum()} valid responses'
975
+ )
976
+ return df
977
+
978
+
979
+ # ============================================================================
980
+ # Convenience Functions
981
+ # ============================================================================
982
+
983
+
984
+ def make_design(
985
+ state: int,
986
+ K1: int = 3,
987
+ K2: int = 2,
988
+ T: int = 8,
989
+ seed: int | None = None,
990
+ **kwargs,
991
+ ) -> pd.DataFrame:
992
+ """
993
+ Generate synthetic data classifying as the requested design state.
994
+
995
+ Bishop's design-state scale assigns an integer 1-6 to each (factor x
996
+ time) grid based on the N_kt distribution. ``make_design(state=N)``
997
+ generates data whose Observed Design State (ODS) classification
998
+ equals ``N``. Uses the three-state vocabulary (PDS / ODS / ADS);
999
+ the integer ``state`` is Bishop's reference scale.
1000
+
1001
+ Args:
1002
+ state: Bishop design-state code (1, 2, 3, 4, 5, or 6).
1003
+ K1: Number of factor 1 levels.
1004
+ K2: Number of factor 2 levels.
1005
+ T: Number of time periods.
1006
+ seed: Random seed for reproducibility.
1007
+ **kwargs: Additional keyword arguments forwarded to the
1008
+ state-specific generator (mix params like ``p_replicated``,
1009
+ ``p_drop``, ``p_singleton``, ``n_min``, ``n_max``).
1010
+
1011
+ Returns:
1012
+ DataFrame with columns ``time``, ``factor 1``, ``factor 2``,
1013
+ ``y`` whose ODS classification equals ``state``. After
1014
+ NA-filtering during tidying, ODS 4/5/6 collapse to ADS 1/2/3.
1015
+
1016
+ Examples:
1017
+ >>> # Round-trip: generated ODS equals requested state
1018
+ >>> for s in (1, 2, 3, 4, 5, 6):
1019
+ ... df = make_design(state=s, seed=42)
1020
+ ... study = pb.ProcessBehavior(df).formulate(
1021
+ ... response='y',
1022
+ ... factors=['factor 1', 'factor 2'],
1023
+ ... time='time',
1024
+ ... )
1025
+ ... assert study.observed_design_state.sds == s
1026
+
1027
+ Raises:
1028
+ ValidationError: If state not in [1, 2, 3, 4, 5, 6].
1029
+ """
1030
+ generators = {1: make_sds1, 2: make_sds2, 3: make_sds3, 4: make_sds4, 5: make_sds5, 6: make_sds6}
1031
+
1032
+ if state not in generators:
1033
+ raise ValidationError(
1034
+ f'Design state {state} not implemented. Available states: {sorted(generators.keys())}'
1035
+ )
1036
+
1037
+ return generators[state](K1=K1, K2=K2, T=T, seed=seed, **kwargs)
1038
+
1039
+
1040
+ # ============================================================================
1041
+ # Edge Cases and Special Scenarios
1042
+ # ============================================================================
1043
+
1044
+
1045
+ def make_edge_cases() -> dict[str, pd.DataFrame]:
1046
+ """
1047
+ Generate challenging edge cases for testing robustness.
1048
+
1049
+ These datasets test how well the system handles unusual but real situations:
1050
+ - Missing cells (incomplete factorial)
1051
+ - Extreme imbalance (one cell with many obs, others with few)
1052
+ - Single observation (should fail gracefully)
1053
+ - All same value (zero variance)
1054
+ - Extreme outliers
1055
+ - Mixed data quality
1056
+
1057
+ Returns:
1058
+ Dictionary of DataFrames, keyed by edge case name
1059
+
1060
+ Examples:
1061
+ >>> edge_cases = make_edge_cases()
1062
+ >>> list(edge_cases.keys())
1063
+ ['missing_cells', 'extreme_imbalance', 'single_obs',
1064
+ 'zero_variance', 'outliers', 'mixed_quality']
1065
+
1066
+ >>> # Test each edge case
1067
+ >>> for name, df in edge_cases.items():
1068
+ ... try:
1069
+ ... result = Analysis(df, spec).calculate()
1070
+ ... print(f"{name}: PASSED")
1071
+ ... except Exception as e:
1072
+ ... print(f"{name}: {type(e).__name__}")
1073
+
1074
+ Use Cases:
1075
+ - Regression testing for error handling
1076
+ - Validating input validation logic
1077
+ - Testing graceful degradation
1078
+ - Documentation of failure modes
1079
+ """
1080
+ cases = {}
1081
+
1082
+ # 1. Missing cells (incomplete factorial)
1083
+ cases['missing_cells'] = pd.DataFrame(
1084
+ {
1085
+ 'time': [1, 1, 2, 2, 3], # No time=3 for factor B
1086
+ 'factor 1': ['A', 'A', 'A', 'B', 'A'],
1087
+ 'factor 2': ['NA'] * 5,
1088
+ 'y': [10.1, 10.2, 11.0, 12.0, 10.5],
1089
+ }
1090
+ )
1091
+
1092
+ # 2. Extreme imbalance (one cell dominates)
1093
+ cases['extreme_imbalance'] = pd.DataFrame(
1094
+ {
1095
+ 'time': [1] * 20 + [2] * 2 + [3] * 20, # Time 2 has only 2 obs
1096
+ 'factor 1': ['A'] * 20 + ['B'] * 2 + ['A'] * 20,
1097
+ 'factor 2': ['NA'] * 42,
1098
+ 'y': np.random.normal(50, 2, 42),
1099
+ }
1100
+ )
1101
+
1102
+ # 3. Single observation total (should fail)
1103
+ cases['single_obs'] = pd.DataFrame({'time': [1], 'factor 1': ['A'], 'factor 2': ['NA'], 'y': [50.0]})
1104
+
1105
+ # 4. Zero variance (all values identical)
1106
+ cases['zero_variance'] = pd.DataFrame(
1107
+ {
1108
+ 'time': [1, 1, 2, 2, 3, 3],
1109
+ 'factor 1': ['A', 'A', 'B', 'B', 'A', 'B'],
1110
+ 'factor 2': ['NA'] * 6,
1111
+ 'y': [50.0] * 6, # All identical
1112
+ }
1113
+ )
1114
+
1115
+ # 5. Extreme outliers
1116
+ rng = np.random.default_rng(42)
1117
+ y_with_outliers = list(rng.normal(50, 1, 18))
1118
+ y_with_outliers[5] = 100.0 # Extreme high
1119
+ y_with_outliers[12] = 0.0 # Extreme low
1120
+
1121
+ cases['outliers'] = pd.DataFrame(
1122
+ {
1123
+ 'time': [1, 1, 2, 2, 3, 3] * 3,
1124
+ 'factor 1': ['A'] * 6 + ['B'] * 6 + ['C'] * 6,
1125
+ 'factor 2': ['NA'] * 18,
1126
+ 'y': y_with_outliers,
1127
+ }
1128
+ )
1129
+
1130
+ # 6. Mixed quality (some cells have high variance, others low)
1131
+ y_mixed = (
1132
+ list(rng.normal(50, 0.1, 6)) # Low variance
1133
+ + list(rng.normal(50, 5.0, 6)) # High variance
1134
+ + list(rng.normal(50, 0.5, 6))
1135
+ ) # Medium variance
1136
+
1137
+ cases['mixed_quality'] = pd.DataFrame(
1138
+ {
1139
+ 'time': [1, 1, 2, 2, 3, 3] * 3,
1140
+ 'factor 1': ['A'] * 6 + ['B'] * 6 + ['C'] * 6,
1141
+ 'factor 2': ['NA'] * 18,
1142
+ 'y': y_mixed,
1143
+ }
1144
+ )
1145
+
1146
+ # 7. Single cell with replication (rest empty)
1147
+ cases['single_cell_replicated'] = pd.DataFrame(
1148
+ {'time': [1] * 5, 'factor 1': ['A'] * 5, 'factor 2': ['NA'] * 5, 'y': [49.5, 50.0, 50.5, 50.2, 49.8]}
1149
+ )
1150
+
1151
+ # 8. Nearly empty (very sparse data)
1152
+ cases['sparse'] = pd.DataFrame(
1153
+ {
1154
+ 'time': [1, 5, 10, 15, 20],
1155
+ 'factor 1': ['A', 'B', 'A', 'C', 'B'],
1156
+ 'factor 2': ['NA'] * 5,
1157
+ 'y': [50.1, 49.8, 50.3, 49.5, 50.0],
1158
+ }
1159
+ )
1160
+
1161
+ return cases
1162
+
1163
+
1164
+ # ============================================================================
1165
+ # Validation and Comparison Utilities
1166
+ # ============================================================================
1167
+
1168
+
1169
+ def compare_sds_characteristics(
1170
+ sds_list: list[int] | None = None, K1: int = 3, K2: int = 2, T: int = 8, seed: int = 42
1171
+ ) -> pd.DataFrame:
1172
+ """
1173
+ Generate comparison table of SDS characteristics.
1174
+
1175
+ Creates a summary table comparing key features of each SDS type,
1176
+ useful for documentation and teaching.
1177
+
1178
+ Args:
1179
+ sds_list: List of SDS to compare (default: all)
1180
+ K1: Factor 1 levels for comparison
1181
+ K2: Factor 2 levels for comparison
1182
+ T: Time periods for comparison
1183
+ seed: Random seed
1184
+
1185
+ Returns:
1186
+ DataFrame with SDS comparison metrics
1187
+
1188
+ Example:
1189
+ >>> summary = compare_sds_characteristics()
1190
+ >>> print(summary)
1191
+
1192
+ sds n_obs n_cells min_n max_n has_replication complete_grid
1193
+ 1 144 48 2 4 True True
1194
+ 2 48 48 1 1 False True
1195
+ 3 96 48 1 3 Mixed True
1196
+ 4 40 40 1 1 False False
1197
+ 5 38 38 1 1 False False
1198
+ 6 84 84 1 1 False False
1199
+ """
1200
+ if sds_list is None:
1201
+ sds_list = [1, 2, 3, 4, 5, 6]
1202
+
1203
+ rows = []
1204
+ for sds in sds_list:
1205
+ df = make_design(sds, K1=K1, K2=K2, T=T, seed=seed)
1206
+
1207
+ if 'factor 1' in df.columns and 'time' in df.columns:
1208
+ cell_counts = df.groupby(['factor 1', 'time']).size()
1209
+ complete = len(cell_counts) == df['factor 1'].nunique() * df['time'].nunique()
1210
+ else:
1211
+ cell_counts = pd.Series([1] * len(df))
1212
+ complete = False
1213
+
1214
+ min_n = cell_counts.min()
1215
+ max_n = cell_counts.max()
1216
+
1217
+ if min_n == max_n == 1:
1218
+ replication = 'False'
1219
+ elif min_n >= 2:
1220
+ replication = 'True'
1221
+ else:
1222
+ replication = 'Mixed'
1223
+
1224
+ rows.append(
1225
+ {
1226
+ 'sds': sds,
1227
+ 'n_obs': len(df),
1228
+ 'n_cells': len(cell_counts),
1229
+ 'min_n': min_n,
1230
+ 'max_n': max_n,
1231
+ 'has_replication': replication,
1232
+ 'complete_grid': complete,
1233
+ }
1234
+ )
1235
+
1236
+ return pd.DataFrame(rows)
1237
+
1238
+
1239
+ def validate_sds_detection(df: pd.DataFrame, expected_sds: int, spec: 'FormulationSpec') -> bool:
1240
+ """
1241
+ Validate that generated data is correctly classified as expected SDS.
1242
+
1243
+ This function is useful for testing SDS detection algorithms by
1244
+ generating data with known SDS and verifying correct classification.
1245
+
1246
+ Args:
1247
+ df: DataFrame to validate
1248
+ expected_sds: Expected SDS classification (1-6)
1249
+ spec: FormulationSpec object
1250
+
1251
+ Returns:
1252
+ True if detected SDS matches expected
1253
+
1254
+ Example:
1255
+ >>> df = make_sds1(K1=3, K2=2, T=8, seed=42)
1256
+ >>> spec = FormulationSpec(response_var='Y', rsg_vars=('F1', 'F2'), time_var='T')
1257
+ >>> validate_sds_detection(df, expected_sds=1, spec)
1258
+ True
1259
+
1260
+ Note:
1261
+ This requires importing from analysis_dataset module.
1262
+ Kept here for convenience in testing.
1263
+ """
1264
+ try:
1265
+ from ..analysis_dataset import AnalysisDataSet
1266
+
1267
+ ads = AnalysisDataSet(df=df, spec=spec, observed_sds=expected_sds)
1268
+ detected_sds = ads.observed_design_state
1269
+
1270
+ if detected_sds == expected_sds:
1271
+ logger.info(f'✓ Correctly detected SDS {expected_sds}')
1272
+ return True
1273
+ else:
1274
+ logger.error(f'✗ SDS detection mismatch: expected {expected_sds}, detected {detected_sds}')
1275
+ return False
1276
+
1277
+ except ImportError:
1278
+ logger.warning('Cannot validate SDS detection: analysis_dataset module not available')
1279
+ return False
1280
+
1281
+
1282
+ # ============================================================================
1283
+ # Batch Generation Utilities
1284
+ # ============================================================================
1285
+
1286
+
1287
+ def generate_test_suite(output_dir: str = 'test_data', seed: int = 42) -> dict[str, str]:
1288
+ """
1289
+ Generate complete suite of test datasets and save to disk.
1290
+
1291
+ Creates CSV files for all SDS types with multiple variations,
1292
+ useful for:
1293
+ - Regression testing
1294
+ - Benchmarking
1295
+ - Documentation examples
1296
+ - Training materials
1297
+
1298
+ Args:
1299
+ output_dir: Directory to save CSV files
1300
+ seed: Random seed for reproducibility
1301
+
1302
+ Returns:
1303
+ Dictionary mapping dataset names to file paths
1304
+
1305
+ Example:
1306
+ >>> files = generate_test_suite(output_dir='datasets/test')
1307
+ >>> print(f"Generated {len(files)} test datasets")
1308
+ >>> for name, path in files.items():
1309
+ ... print(f" {name}: {path}")
1310
+
1311
+ File Structure:
1312
+ test_data/
1313
+ sds1_basic.csv
1314
+ sds1_large.csv
1315
+ sds1_with_truth.csv
1316
+ sds2_basic.csv
1317
+ sds2_large.csv
1318
+ ...
1319
+ """
1320
+ import os
1321
+
1322
+ # Create output directory
1323
+ os.makedirs(output_dir, exist_ok=True)
1324
+
1325
+ files = {}
1326
+
1327
+ # SDS 1 variations
1328
+ files['sds1_basic'] = os.path.join(output_dir, 'sds1_basic.csv')
1329
+ df = make_sds1(K1=3, K2=2, T=8, seed=seed)
1330
+ df.to_csv(files['sds1_basic'], index=False)
1331
+
1332
+ files['sds1_large'] = os.path.join(output_dir, 'sds1_large.csv')
1333
+ df = make_sds1(K1=5, K2=2, T=12, n_min=3, n_max=5, seed=seed)
1334
+ df.to_csv(files['sds1_large'], index=False)
1335
+
1336
+ files['sds1_with_truth'] = os.path.join(output_dir, 'sds1_with_truth.csv')
1337
+ df = make_sds1(K1=3, K2=2, T=6, include_truth=True, seed=seed)
1338
+ df.to_csv(files['sds1_with_truth'], index=False)
1339
+
1340
+ # SDS 2 variations
1341
+ files['sds2_basic'] = os.path.join(output_dir, 'sds2_basic.csv')
1342
+ df = make_sds2(K1=3, K2=2, T=10, seed=seed)
1343
+ df.to_csv(files['sds2_basic'], index=False)
1344
+
1345
+ files['sds2_large'] = os.path.join(output_dir, 'sds2_large.csv')
1346
+ df = make_sds2(K1=6, K2=2, T=16, seed=seed)
1347
+ df.to_csv(files['sds2_large'], index=False)
1348
+
1349
+ # SDS 3 variations (critical!)
1350
+ files['sds3_balanced'] = os.path.join(output_dir, 'sds3_balanced.csv')
1351
+ df = make_sds3(K1=3, K2=2, T=8, p_replicated=0.5, seed=seed)
1352
+ df.to_csv(files['sds3_balanced'], index=False)
1353
+
1354
+ files['sds3_sparse'] = os.path.join(output_dir, 'sds3_sparse.csv')
1355
+ df = make_sds3(K1=4, K2=2, T=10, p_replicated=0.2, seed=seed)
1356
+ df.to_csv(files['sds3_sparse'], index=False)
1357
+
1358
+ files['sds3_early_times'] = os.path.join(output_dir, 'sds3_early_times.csv')
1359
+ df = make_sds3(K1=3, K2=2, T=12, replication_pattern='early_times', seed=seed)
1360
+ df.to_csv(files['sds3_early_times'], index=False)
1361
+
1362
+ # SDS 4 variations (ODS 4: incomplete grid, all occupied cells replicated)
1363
+ files['sds4_default'] = os.path.join(output_dir, 'sds4_default.csv')
1364
+ df = make_sds4(K1=3, K2=2, T=8, seed=seed)
1365
+ df.to_csv(files['sds4_default'], index=False)
1366
+
1367
+ files['sds4_sparse'] = os.path.join(output_dir, 'sds4_sparse.csv')
1368
+ df = make_sds4(K1=3, K2=2, T=10, p_drop=0.5, seed=seed)
1369
+ df.to_csv(files['sds4_sparse'], index=False)
1370
+
1371
+ # SDS 5 variations (ODS 5: incomplete grid, all occupied cells singleton)
1372
+ files['sds5_default'] = os.path.join(output_dir, 'sds5_default.csv')
1373
+ df = make_sds5(K1=3, K2=2, T=8, seed=seed)
1374
+ df.to_csv(files['sds5_default'], index=False)
1375
+
1376
+ files['sds5_sparse'] = os.path.join(output_dir, 'sds5_sparse.csv')
1377
+ df = make_sds5(K1=4, K2=3, T=12, p_drop=0.5, seed=seed)
1378
+ df.to_csv(files['sds5_sparse'], index=False)
1379
+
1380
+ # SDS 6 variations (ODS 6: incomplete grid, mixed singleton / replicated)
1381
+ files['sds6_default'] = os.path.join(output_dir, 'sds6_default.csv')
1382
+ df = make_sds6(K1=3, K2=2, T=8, seed=seed)
1383
+ df.to_csv(files['sds6_default'], index=False)
1384
+
1385
+ files['sds6_singleton_heavy'] = os.path.join(output_dir, 'sds6_singleton_heavy.csv')
1386
+ df = make_sds6(K1=4, K2=2, T=10, p_singleton=0.7, seed=seed)
1387
+ df.to_csv(files['sds6_singleton_heavy'], index=False)
1388
+
1389
+ # Edge cases
1390
+ edge_cases = make_edge_cases()
1391
+ for name, df in edge_cases.items():
1392
+ files[f'edge_{name}'] = os.path.join(output_dir, f'edge_{name}.csv')
1393
+ df.to_csv(files[f'edge_{name}'], index=False)
1394
+
1395
+ logger.info(f'Generated {len(files)} test datasets in {output_dir}')
1396
+
1397
+ return files
1398
+
1399
+
1400
+ # ============================================================================
1401
+ # Documentation and Metadata
1402
+ # ============================================================================
1403
+
1404
+
1405
+ def get_sds_info(sds: int) -> dict[str, str]:
1406
+ """
1407
+ Get detailed information about a specific SDS type.
1408
+
1409
+ Returns metadata and usage guidance for each SDS.
1410
+
1411
+ Args:
1412
+ sds: Sampling Design State (1-6)
1413
+
1414
+ Returns:
1415
+ Dictionary with description, characteristics, use cases, etc.
1416
+
1417
+ Example:
1418
+ >>> info = get_sds_info(3)
1419
+ >>> print(info['description'])
1420
+ >>> print(info['when_to_use'])
1421
+ """
1422
+ sds_metadata = {
1423
+ 1: {
1424
+ 'name': 'Full Replication',
1425
+ 'description': 'Every (factor, time) cell has n≥2 observations',
1426
+ 'characteristics': [
1427
+ 'True within-cell variance estimation',
1428
+ 'Complete (factor × time) grid',
1429
+ 'Supports full interaction analysis',
1430
+ 'Most statistically powerful',
1431
+ ],
1432
+ 'when_to_use': [
1433
+ 'Testing Xbar-S chart algorithms',
1434
+ 'Full factorial experiments',
1435
+ 'When resources allow complete replication',
1436
+ 'Baseline for comparing other SDS',
1437
+ ],
1438
+ 'chart_types': ['Xbar-S', 'Range charts on subgroups'],
1439
+ 'vas_capabilities': ['All residuals R1-R5', 'Full interaction effects'],
1440
+ 'common_in': 'Designed experiments, validation studies',
1441
+ },
1442
+ 2: {
1443
+ 'name': 'No Replication',
1444
+ 'description': 'Exactly one observation per (factor, time) cell',
1445
+ 'characteristics': [
1446
+ 'Complete (factor × time) grid',
1447
+ 'No within-cell variance',
1448
+ 'Must use moving average for R2',
1449
+ 'Error confounded with interaction',
1450
+ ],
1451
+ 'when_to_use': [
1452
+ 'Resource-constrained experiments',
1453
+ 'Screening studies',
1454
+ 'Testing moving average algorithms',
1455
+ 'When interaction assumed small',
1456
+ ],
1457
+ 'chart_types': ['IMR on cell means', 'Moving range methods'],
1458
+ 'vas_capabilities': ['R1-R5 with approximated R2', 'Limited interaction analysis'],
1459
+ 'common_in': 'Designed experiments, DOE, initial screening',
1460
+ },
1461
+ 3: {
1462
+ 'name': 'Partial Replication',
1463
+ 'description': 'Mixed: some cells n=1, others n≥2',
1464
+ 'characteristics': [
1465
+ 'Most common in real-world data',
1466
+ 'Requires hybrid R2 estimation',
1467
+ 'Irregular replication pattern',
1468
+ 'Challenging to analyze correctly',
1469
+ ],
1470
+ 'when_to_use': [
1471
+ 'Testing hybrid variance estimation',
1472
+ 'Real-world manufacturing data',
1473
+ 'Unplanned replication scenarios',
1474
+ 'Missing data situations',
1475
+ ],
1476
+ 'chart_types': ['Hybrid approaches', 'Adaptive methods'],
1477
+ 'vas_capabilities': ['Hybrid R2 calculation', 'Partial interaction analysis'],
1478
+ 'common_in': 'Most real production data, unplanned sampling',
1479
+ },
1480
+ 4: {
1481
+ 'name': 'Single Condition Over Time',
1482
+ 'description': 'One factor level, multiple time points (K=1)',
1483
+ 'characteristics': [
1484
+ 'Time series structure',
1485
+ 'No grouping by factors',
1486
+ 'May show trends or drift',
1487
+ 'Classic control chart scenario',
1488
+ ],
1489
+ 'when_to_use': [
1490
+ 'Single process monitoring',
1491
+ 'Time series analysis',
1492
+ 'Trend detection',
1493
+ 'Classic SPC applications',
1494
+ ],
1495
+ 'chart_types': ['IMR', 'Individuals chart', 'Moving range'],
1496
+ 'vas_capabilities': ['Limited (no factor structure)', 'Time effects only'],
1497
+ 'common_in': 'Single machine/process, continuous monitoring',
1498
+ },
1499
+ 5: {
1500
+ 'name': 'Nested Design',
1501
+ 'description': 'Hierarchical factors with asynchronous coverage',
1502
+ 'characteristics': [
1503
+ 'Factor 2 nested in Factor 1',
1504
+ 'Irregular temporal patterns',
1505
+ 'Not all combinations at all times',
1506
+ 'Requires variance components',
1507
+ ],
1508
+ 'when_to_use': [
1509
+ 'Multi-head machines (heads in lanes)',
1510
+ 'Hierarchical production structures',
1511
+ 'Nested experimental designs',
1512
+ 'Asynchronous operations',
1513
+ ],
1514
+ 'chart_types': ['Hierarchical charts', 'Nested variance components'],
1515
+ 'vas_capabilities': ['Nested effects', 'Variance component estimation'],
1516
+ 'common_in': 'Multi-head fillers, multi-spindle machines, nested processes',
1517
+ },
1518
+ 6: {
1519
+ 'name': 'Unstructured / Regime Changes',
1520
+ 'description': 'Irregular patterns with process regime changes',
1521
+ 'characteristics': [
1522
+ 'Incomplete (factor × time) grid',
1523
+ 'Mean shifts over time (regimes)',
1524
+ 'Irregular sampling patterns',
1525
+ 'Complex time structure',
1526
+ ],
1527
+ 'when_to_use': [
1528
+ 'Long-term process studies',
1529
+ 'Process with adjustments',
1530
+ 'Regime detection problems',
1531
+ 'Irregular production schedules',
1532
+ ],
1533
+ 'chart_types': ['Adaptive methods', 'Change-point detection'],
1534
+ 'vas_capabilities': ['Limited', 'Regime-specific analysis'],
1535
+ 'common_in': 'Long-term studies, processes with changes, irregular schedules',
1536
+ },
1537
+ }
1538
+
1539
+ if sds not in sds_metadata:
1540
+ raise ValidationError(f'SDS {sds} not defined. Valid: 1-6')
1541
+
1542
+ return sds_metadata[sds]
1543
+
1544
+
1545
+ def print_sds_summary():
1546
+ """
1547
+ Print formatted summary of all SDS types.
1548
+
1549
+ Useful for documentation and teaching.
1550
+
1551
+ Example:
1552
+ >>> print_sds_summary()
1553
+
1554
+ SAMPLING DESIGN STATES SUMMARY
1555
+ ==============================
1556
+
1557
+ SDS 1: Full Replication
1558
+ -----------------------
1559
+ Description: Every (factor, time) cell has n≥2 observations
1560
+ ...
1561
+ """
1562
+ print('\nSAMPLING DESIGN STATES SUMMARY')
1563
+ print('=' * 70)
1564
+
1565
+ for sds in range(1, 7):
1566
+ info = get_sds_info(sds)
1567
+ print(f'\nSDS {sds}: {info["name"]}')
1568
+ print('-' * 70)
1569
+ print(f'Description: {info["description"]}')
1570
+ print('\nCharacteristics:')
1571
+ for char in info['characteristics']:
1572
+ print(f' • {char}')
1573
+ print(f'\nCommon in: {info["common_in"]}')
1574
+ print(f'Chart types: {", ".join(info["chart_types"])}')
1575
+
1576
+
1577
+ # ============================================================================
1578
+ # Large Dataset Generator for Performance Testing
1579
+ # ============================================================================
1580
+
1581
+
1582
+ def make_large_dataset(
1583
+ n_rows: int = 1_000_000,
1584
+ n_extra_cols: int = 46,
1585
+ sds: int = 1,
1586
+ seed: int = 42,
1587
+ ) -> pd.DataFrame:
1588
+ """
1589
+ Generate large dataset for performance testing.
1590
+
1591
+ Creates dataset with specified row count plus extra columns that won't
1592
+ be used in analysis, simulating real-world data dumps with many unused
1593
+ columns.
1594
+
1595
+ This is useful for:
1596
+ - Performance benchmarking
1597
+ - Memory usage testing
1598
+ - Scalability validation
1599
+ - Stress testing the analysis pipeline
1600
+
1601
+ Args:
1602
+ n_rows: Target number of rows (approximate, actual may vary slightly
1603
+ due to SDS structure constraints)
1604
+ n_extra_cols: Extra columns to add beyond the 4 core analysis columns
1605
+ (factor 1, factor 2, time, y). Default 46 gives 50 total.
1606
+ sds: Sampling Design State to generate (1, 2, or 4 supported for large)
1607
+ seed: Random seed for reproducibility
1608
+
1609
+ Returns:
1610
+ DataFrame with approximately n_rows and (4 + n_extra_cols) columns
1611
+
1612
+ Examples:
1613
+ >>> # Generate 1M row dataset with 50 columns
1614
+ >>> df = make_large_dataset(n_rows=1_000_000, n_extra_cols=46)
1615
+ >>> len(df)
1616
+ 1000000
1617
+ >>> len(df.columns)
1618
+ 50
1619
+
1620
+ >>> # Generate 100K rows for faster testing
1621
+ >>> df = make_large_dataset(n_rows=100_000, seed=42)
1622
+
1623
+ >>> # Test SDS 2 (no replication) at scale
1624
+ >>> df = make_large_dataset(n_rows=500_000, sds=2)
1625
+
1626
+ Notes:
1627
+ - SDS 1: Uses K1 × K2 factors × T times × n replicates = n_rows
1628
+ - SDS 2: Uses K1 × K2 factors × T times = n_rows (no replication)
1629
+ - SDS 4: Uses T time points = n_rows (single factor)
1630
+ - Extra columns are randomly typed (60% float, 20% int, 20% string)
1631
+ to simulate realistic mixed-type data exports.
1632
+ """
1633
+ rng = np.random.default_rng(seed)
1634
+
1635
+ if sds == 1:
1636
+ # Calculate K1, K2, T, n to hit target rows
1637
+ # K1 × K2 × T × n = n_rows
1638
+ n = 10 # Fixed replication
1639
+ K2 = 2 # Fixed K2
1640
+ cells = n_rows // (n * K2)
1641
+ K1 = int(np.sqrt(cells))
1642
+ T = cells // K1
1643
+
1644
+ df = make_sds1(K1=K1, K2=K2, T=T, n_min=n, n_max=n, seed=seed)
1645
+
1646
+ elif sds == 2:
1647
+ # K1 × K2 × T = n_rows (no replication)
1648
+ K2 = 2 # Fixed K2
1649
+ remaining = n_rows // K2
1650
+ K1 = int(np.sqrt(remaining))
1651
+ T = remaining // K1
1652
+ df = make_sds2(K1=K1, K2=K2, T=T, seed=seed)
1653
+
1654
+ elif sds == 4:
1655
+ # ODS 4: incomplete grid, all occupied cells replicated.
1656
+ # Scale K1, K2, T to roughly hit n_rows total observations.
1657
+ K1 = max(2, int(np.sqrt(n_rows // 8)))
1658
+ K2 = max(2, int(np.sqrt(n_rows // 8)))
1659
+ T = max(2, n_rows // (K1 * K2 * 3))
1660
+ df = make_sds4(K1=K1, K2=K2, T=T, seed=seed)
1661
+
1662
+ else:
1663
+ raise ValidationError(
1664
+ f'SDS {sds} not optimized for large datasets. '
1665
+ f'Supported: 1 (full replication), 2 (no replication), 4 (incomplete grid)'
1666
+ )
1667
+
1668
+ # Add extra columns with mixed data types
1669
+ for i in range(n_extra_cols):
1670
+ dtype_choice = rng.choice(['float', 'int', 'str'], p=[0.6, 0.2, 0.2])
1671
+
1672
+ if dtype_choice == 'float':
1673
+ df[f'extra_{i}'] = rng.normal(0, 100, len(df))
1674
+ elif dtype_choice == 'int':
1675
+ df[f'extra_{i}'] = rng.integers(0, 1000, len(df))
1676
+ else:
1677
+ df[f'extra_{i}'] = rng.choice(['A', 'B', 'C', 'D'], len(df))
1678
+
1679
+ logger.info(f'Generated large dataset: {len(df):,} rows × {len(df.columns)} cols')
1680
+ return df
1681
+
1682
+
1683
+ # ============================================================================
1684
+ # Module-level convenience
1685
+ # ============================================================================
1686
+
1687
+ # Quick access to all generators (SDS 1-6)
1688
+ # Note: SDS 0 was consolidated into SDS 4. Response-only data is now
1689
+ # treated as SDS 4 with implicit time ordering via obs_id.
1690
+ GENERATORS = {1: make_sds1, 2: make_sds2, 3: make_sds3, 4: make_sds4, 5: make_sds5, 6: make_sds6}
1691
+
1692
+
1693
+ if __name__ == '__main__':
1694
+ # Demo: Generate one dataset of each type
1695
+ print('Generating example datasets for each SDS...\n')
1696
+
1697
+ for sds in range(1, 7):
1698
+ print(f'SDS {sds}: {get_sds_info(sds)["name"]}')
1699
+ df = make_design(sds, K1=3, K2=2, T=6, seed=42)
1700
+ print(f' Generated {len(df)} observations')
1701
+ print(f' Columns: {df.columns.tolist()}')
1702
+ print(f' Preview:\n{df.head(3)}')
1703
+ print()
1704
+
1705
+ print('\nEdge cases:')
1706
+ edge_cases = make_edge_cases()
1707
+ for name, df in edge_cases.items():
1708
+ print(f' {name}: {len(df)} observations')
1709
+
1710
+ print('\nFor detailed info on any SDS, use get_sds_info(sds_number)')
1711
+ print('For complete summary, use print_sds_summary()')