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,1432 @@
1
+ """
2
+ Sampling Design State (SDS) detection for process behavior analysis.
3
+
4
+ This module implements the complete SDS classification system from the
5
+ Variance Analysis System (VAS) framework by Bishop.
6
+
7
+ The SDS determines:
8
+ - What type of data structure we have
9
+ - What residual calculations are appropriate
10
+ - What variance estimation methods to use
11
+ - What analyses are supported
12
+
13
+ Follows the Pythonic Hadley philosophy:
14
+ - Clear, self-documenting logic
15
+ - Helpful error messages
16
+ - Explicit over implicit
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import logging
22
+ from dataclasses import dataclass, replace
23
+ from typing import TYPE_CHECKING, ClassVar, Literal
24
+
25
+ import pandas as pd
26
+
27
+ from .exceptions import ValidationError
28
+
29
+ # Formal vocabulary for SDS classification reasons (per Bishop Table 1)
30
+ SDSReasonType = Literal[
31
+ # Complete/Semi-Complete (SDS 1, 2, 3) - no empty cells
32
+ 'full_replication', # SDS 1: all N_kt >= 2
33
+ 'no_replication', # SDS 2: all N_kt = 1
34
+ 'partial_replication', # SDS 3: mixed N_kt (some 1, some >=2)
35
+ # Incomplete (SDS 4, 5, 6) - has empty cells (N_kt = 0)
36
+ 'incomplete_no_singletons', # SDS 4: has 0s, no 1s, has >=2s
37
+ 'incomplete_no_replication', # SDS 5: has 0s, max = 1
38
+ 'incomplete_with_singletons', # SDS 6: has 0s, has 1s, has >=2s
39
+ ]
40
+
41
+ # R2 calculation method - structure-driven, not SDS-driven
42
+ R2Method = Literal['exact', 'ma2', 'hybrid']
43
+
44
+
45
+ @dataclass(frozen=True)
46
+ class StructureStats:
47
+ """
48
+ Observed structure of the data - drives R2 method selection and availability checks.
49
+
50
+ This is computed once in AnalysisDataSet and threaded through to avoid
51
+ recomputing structure statistics in multiple places.
52
+
53
+ Attributes
54
+ ----------
55
+ has_grouping : bool
56
+ Whether factor variables (rsg_vars) are present
57
+ has_order : bool
58
+ Whether ordering key (obs_id) exists
59
+ n_cell_min : int
60
+ Minimum observations per (rsg_key, time) cell
61
+ n_cell_max : int
62
+ Maximum observations per (rsg_key, time) cell
63
+ K_obs : int
64
+ Number of unique rsg_key values (for R5 availability check)
65
+ """
66
+
67
+ has_grouping: bool
68
+ has_order: bool
69
+ n_cell_min: int
70
+ n_cell_max: int
71
+ K_obs: int
72
+
73
+
74
+ if TYPE_CHECKING:
75
+ from .formulation_spec import FormulationSpec
76
+
77
+ logger = logging.getLogger(__name__)
78
+
79
+
80
+ # ============================================================================
81
+ # SDS Analysis Plan - The "Recipe" for Each Sampling Design State
82
+ # ============================================================================
83
+
84
+
85
+ @dataclass
86
+ class SDSAnalysisPlan:
87
+ """
88
+ Complete specification of what analysis can be performed for a given SDS.
89
+
90
+ This is the authoritative "recipe" that the system follows when it detects
91
+ a particular Sampling Design State. It defines capabilities and limitations
92
+ for each SDS, enabling validation against Bishop's methodology.
93
+
94
+ Attributes
95
+ ----------
96
+ sds : int
97
+ Sampling Design State (0-6)
98
+ name : str
99
+ Short descriptive name
100
+ description : str
101
+ Detailed description of the data structure
102
+ has_factors : bool
103
+ Whether grouping/factor variables are present
104
+ has_time : bool
105
+ Whether time variable is present
106
+ has_replication : str
107
+ Replication pattern: 'full', 'partial', or 'none'
108
+ valid_charts : list[str]
109
+ Chart types that can be used with this SDS
110
+ recommended_chart : str
111
+ Default/recommended chart type
112
+ invalid_charts : list[str]
113
+ Chart types that cannot be used (with reasons)
114
+ vas_residuals_supported : bool
115
+ Whether VAS residual calculations (R1-R5) are supported
116
+ residuals_available : list[str]
117
+ Which residuals can be calculated
118
+ residual_calculation_method : str
119
+ How R2 is calculated ('exact', 'moving_average', 'hybrid', or 'none')
120
+ main_effects_supported : bool
121
+ Whether main effect calculations are supported
122
+ interaction_effects_supported : bool
123
+ Whether interaction effect calculations are supported
124
+ supports_stratification : bool
125
+ Whether data can be split into stratified charts
126
+ typical_use_cases : list[str]
127
+ Common real-world scenarios
128
+ limitations : list[str]
129
+ What cannot be done with this SDS
130
+ bishop_reference : str
131
+ Reference to Bishop methodology
132
+
133
+ Examples
134
+ --------
135
+ >>> plan = SDSRegistry.get_analysis_plan(sds=1)
136
+ >>> print(plan.name)
137
+ 'Full Factorial with Complete Replication'
138
+ >>> print(plan.vas_residuals_supported)
139
+ True
140
+ >>> print(plan.valid_charts)
141
+ ['Xbar', 'S', 'X']
142
+ """
143
+
144
+ sds: int
145
+ name: str
146
+ description: str
147
+
148
+ # Data structure characteristics
149
+ has_factors: bool
150
+ has_time: bool
151
+ has_replication: str # 'full', 'partial', 'none'
152
+
153
+ # Valid chart types
154
+ valid_charts: list[str]
155
+ recommended_chart: str
156
+ invalid_charts: list[str]
157
+
158
+ # VAS residual capabilities
159
+ vas_residuals_supported: bool
160
+ residuals_available: list[str]
161
+ residual_calculation_method: str # 'exact', 'moving_average', 'hybrid', 'none'
162
+
163
+ # Effects and interactions
164
+ main_effects_supported: bool
165
+ interaction_effects_supported: bool
166
+
167
+ # Stratification
168
+ supports_stratification: bool
169
+
170
+ # Use cases and notes
171
+ typical_use_cases: list[str]
172
+ limitations: list[str]
173
+ bishop_reference: str
174
+
175
+ # Actual data characteristics (populated during detection)
176
+ min_cell_size: int = 0 # Minimum observations per cell from actual data
177
+
178
+ # Class constant: questions each chart type answers
179
+ # Primary charts keyed by str, residual charts keyed by (chart_type, residual) tuple
180
+ CHART_QUESTIONS: ClassVar[dict[str | tuple[str, str], str]] = {
181
+ # Primary charts
182
+ 'Xbar': 'Are subgroup means stable over time?',
183
+ 'S': 'Is within-subgroup variation stable?',
184
+ 'X': 'Is individual variation stable over time?',
185
+ 'mR': 'Is range variation stable over time?',
186
+ 'Histogram': 'What shape does the response distribution take?',
187
+ # Residual charts — keyed by (chart_type, residual)
188
+ ('Xbar', 'R1'): 'How do subgroup means vary about the overall mean?',
189
+ ('X', 'R1'): 'How do individual values vary about the overall mean?',
190
+ ('S', 'R2'): 'Is within-subgroup variation stable?',
191
+ ('X', 'R2'): 'Is within-subgroup variation stable?',
192
+ ('X', 'R3'): 'Is there factor×time interaction?',
193
+ ('Xbar', 'R3'): 'Is there factor×time interaction affecting the mean?',
194
+ ('S', 'R3'): 'Is there factor×time interaction affecting variation?',
195
+ ('X', 'R4'): 'Does time have a significant effect?',
196
+ ('Xbar', 'R4'): 'Does time have a significant effect on the mean?',
197
+ ('S', 'R4'): 'Does time have a significant effect on variation?',
198
+ ('X', 'R5'): 'Does the factor have a significant effect?',
199
+ ('Xbar', 'R5'): 'Do factors have a significant effect on the mean?',
200
+ ('S', 'R5'): 'Do factors have a significant effect on variation?',
201
+ ('Xbar', 'R6'): 'Do individual factors have a significant effect on the mean?',
202
+ ('S', 'R6'): 'Do individual factors have a significant effect on variation?',
203
+ }
204
+
205
+ @property
206
+ def residual_charts(self) -> list[tuple[str, str]]:
207
+ """
208
+ Residual chart types available for this SDS.
209
+
210
+ Returns (chart_type, residual) tuples matching the ``execute()``
211
+ signature: ``study.execute(chart=chart_type, value=residual)``.
212
+
213
+ - R1: Xbar/X only — the response centred at 0 (location, never dispersion)
214
+ - R2: S chart when cells have n>=2, otherwise X
215
+ - R3: Xbar/S when cells have n>=2, otherwise X
216
+ - R4: Xbar/S when has_factors (aggregate across factors by time)
217
+ - R5: Xbar/S when has_time (aggregate across time by factor)
218
+ - R6: Always Xbar/S (factor main effects aggregate across time)
219
+
220
+ Per Bishop Sections 20.6.1-4:
221
+ - R2 uses (k,t) cell subgrouping - S when n>=2
222
+ - R3 uses (k,t) cell subgrouping - Xbar/S when n>=2
223
+ - R4 Xbar/S use time-based subgrouping (N_.t = Σ_k N_kt)
224
+ - R5 Xbar/S use factor-based subgrouping (N_k. = Σ_t N_kt)
225
+ - R6 Xbar/S use factor-based subgrouping (requires by= at execute time)
226
+ """
227
+ if not self.vas_residuals_supported:
228
+ return []
229
+
230
+ # R1 (Bishop 13.1): the response centred at 0 — a location shift, so only the
231
+ # location charts. X is unconditional: charting the centred raw data is the whole
232
+ # diagnostic. No S/mR — R1 differs from the response by a constant, so its
233
+ # dispersion charts would duplicate the response's exactly.
234
+ r1 = [('Xbar', 'R1'), ('X', 'R1')]
235
+
236
+ # R2: S chart when cells have replication (min_cell_size >= 2)
237
+ # X R2 is always available for Maximum Information Analysis
238
+ r2 = [('S', 'R2')] if self.min_cell_size >= 2 else []
239
+ r2.append(('X', 'R2'))
240
+
241
+ # R3: Xbar/S when subgroups have n>=2, X for individual observations
242
+ # Both are valid: Xbar/S by=[time] aggregates across factors (n>1),
243
+ # X by=[] or by=[factors,time] charts individual observations.
244
+ # Xbar will raise at compute time if subgroups have n=1.
245
+ r3 = [('Xbar', 'R3'), ('S', 'R3')]
246
+ if self.min_cell_size < 2:
247
+ r3.append(('X', 'R3'))
248
+
249
+ # R4: Xbar/S when aggregating across factors gives n>=2 per time subgroup
250
+ r4 = [('Xbar', 'R4'), ('S', 'R4')] if self.has_factors else [('X', 'R4')]
251
+
252
+ # R5: Xbar/S when aggregating across time gives n>=2 per factor subgroup
253
+ r5 = [('Xbar', 'R5'), ('S', 'R5')] if self.has_time else [('X', 'R5')]
254
+
255
+ # R6: Always Xbar/S — factor main effects aggregate across time,
256
+ # giving n>=2 subgroups regardless of cell-level replication
257
+ r6 = [('Xbar', 'R6'), ('S', 'R6')]
258
+
259
+ return r1 + r2 + r3 + r4 + r5 + r6
260
+
261
+ def __str__(self) -> str:
262
+ """Pretty print the analysis plan."""
263
+ lines = [
264
+ f'SDS {self.sds}: {self.name}',
265
+ '=' * 70,
266
+ f'Description: {self.description}',
267
+ '',
268
+ 'Data Structure:',
269
+ f' • Factors: {"Yes" if self.has_factors else "No"}',
270
+ f' • Time: {"Yes" if self.has_time else "No"}',
271
+ f' • Replication: {self.has_replication.capitalize()}',
272
+ '',
273
+ 'Chart Capabilities:',
274
+ f' • Valid charts: {", ".join(self.valid_charts)}',
275
+ f' • Recommended: {self.recommended_chart}',
276
+ ]
277
+
278
+ if self.invalid_charts:
279
+ lines.append(f' • Invalid charts: {", ".join(self.invalid_charts)}')
280
+
281
+ lines.extend(
282
+ [
283
+ '',
284
+ 'Variance Analysis System (VAS):',
285
+ f' • VAS supported: {"Yes" if self.vas_residuals_supported else "No"}',
286
+ ]
287
+ )
288
+
289
+ if self.vas_residuals_supported:
290
+ lines.append(f' • Residuals: {", ".join(self.residuals_available)}')
291
+ lines.append(f' • R2 calculation: {self.residual_calculation_method}')
292
+
293
+ lines.extend(
294
+ [
295
+ '',
296
+ 'Effects Analysis:',
297
+ f' • Main effects: {"Yes" if self.main_effects_supported else "No"}',
298
+ f' • Interactions: {"Yes" if self.interaction_effects_supported else "No"}',
299
+ f' • Stratification: {"Yes" if self.supports_stratification else "No"}',
300
+ ]
301
+ )
302
+
303
+ if self.typical_use_cases:
304
+ lines.append('')
305
+ lines.append('Typical Use Cases:')
306
+ for use_case in self.typical_use_cases:
307
+ lines.append(f' • {use_case}')
308
+
309
+ if self.limitations:
310
+ lines.append('')
311
+ lines.append('Limitations:')
312
+ for limitation in self.limitations:
313
+ lines.append(f' • {limitation}')
314
+
315
+ lines.extend(
316
+ [
317
+ '',
318
+ f'Reference: {self.bishop_reference}',
319
+ ]
320
+ )
321
+
322
+ return '\n'.join(lines)
323
+
324
+
325
+ @dataclass(frozen=True)
326
+ class SDSResult:
327
+ """
328
+ Result of SDS detection.
329
+
330
+ Attributes
331
+ ----------
332
+ sds : int
333
+ Sampling Design State (0-6). 0 indicates no analyzable observations
334
+ remain after cleaning. 1-6 per Bishop Table 1.
335
+ min_cell_size : int
336
+ Minimum observations per cell (for chart selection)
337
+ reason : SDSReasonType | None
338
+ Why this SDS was detected, based on N_kt distribution.
339
+
340
+ Complete/Semi-Complete (no empty cells):
341
+ - "full_replication": SDS 1 (all N_kt >= 2)
342
+ - "no_replication": SDS 2 (all N_kt = 1)
343
+ - "partial_replication": SDS 3 (mixed N_kt)
344
+
345
+ Incomplete (has empty cells, requires plan):
346
+ - "incomplete_no_singletons": SDS 4 (has 0s and >=2s, no 1s)
347
+ - "incomplete_no_replication": SDS 5 (has 0s, max = 1)
348
+ - "incomplete_with_singletons": SDS 6 (has 0s, 1s, and >=2s)
349
+ n_empty_cells : int
350
+ Count of cells with Nₖₜ=0 after plan reindex (if plan provided).
351
+ For observed-only detection, counts cells where all responses are NA.
352
+ """
353
+
354
+ sds: int
355
+ min_cell_size: int
356
+ reason: SDSReasonType | None = None
357
+ n_empty_cells: int = 0
358
+
359
+
360
+ class SDSRegistry:
361
+ """
362
+ Registry of Sampling Design State (SDS 1-6) definitions and rules.
363
+
364
+ Implements Bishop Table 1 classification based on N_kt distribution.
365
+
366
+ Provides:
367
+ - SDS detection from data structure
368
+ - Analysis plans and capabilities for each SDS
369
+ - Validation of SDS/analysis compatibility
370
+ - VAS residual calculation rules
371
+
372
+ The SDS classification system (per Bishop Table 1):
373
+
374
+ **Complete/Semi-Complete** (no empty cells in grid):
375
+
376
+ **SDS 1**: Complete - Full replication
377
+ - Min N_kt ≥ 2 (all cells have multiple observations)
378
+ - Best for variance estimation
379
+
380
+ **SDS 2**: Semi-Complete - No replication
381
+ - Min N_kt = 1 and Max N_kt = 1 (all cells are singletons)
382
+ - Requires moving average for R2
383
+
384
+ **SDS 3**: Semi-Complete - Partial replication
385
+ - Min N_kt = 1 and Max N_kt ≥ 2 (mixed)
386
+ - Hybrid variance estimation
387
+
388
+ **Incomplete** (has empty cells, requires sampling plan to detect):
389
+
390
+ **SDS 4**: Incomplete without singletons
391
+ - Has empty cells (N_kt = 0) and replicated (N_kt ≥ 2), but NO singletons
392
+ - Can estimate variance from replicated cells
393
+
394
+ **SDS 5**: Incomplete without replication
395
+ - Has empty cells (N_kt = 0) and Max N_kt = 1
396
+ - Cannot estimate within-cell variance
397
+
398
+ **SDS 6**: Incomplete with singletons
399
+ - Has empty cells (N_kt = 0), singletons (N_kt = 1), and replicated (N_kt ≥ 2)
400
+ - Mixed everything
401
+
402
+ Examples
403
+ --------
404
+ Detect SDS from prepared data:
405
+
406
+ >>> registry = SDSRegistry()
407
+ >>> result = registry.detect_sds(df, spec)
408
+ >>> print(f"Detected SDS {result.sds}")
409
+ Detected SDS 1
410
+
411
+ With sampling plan (enables SDS 4-6 detection):
412
+
413
+ >>> plan = {'Lane': [1, 2, 3, 4], 'Phase': [1, 2, 3]}
414
+ >>> result = registry.detect_sds(df, spec, plan=plan)
415
+ """
416
+
417
+ def detect_sds_from_structure(
418
+ self,
419
+ df: pd.DataFrame,
420
+ spec: FormulationSpec,
421
+ response_col: str,
422
+ plan: dict[str, list] | None = None,
423
+ T_planned: int | None = None,
424
+ ) -> SDSResult:
425
+ """
426
+ Detect SDS from raw data structure (before response NA rows are dropped).
427
+
428
+ This matches Tom Bishop's Minitab approach: cells with all-NA responses
429
+ still count as "attempted" cells, revealing the true intended structure.
430
+
431
+ Parameters
432
+ ----------
433
+ df : DataFrame
434
+ Raw data (response NA rows NOT yet dropped)
435
+ spec : FormulationSpec
436
+ Data preparation configuration
437
+ response_col : str
438
+ Name of response column
439
+ plan : dict, optional
440
+ Sampling plan defining intended structure. Should include time column
441
+ values if time-based structure is expected.
442
+ T_planned : int, optional
443
+ Planned number of time points. Only used when plan is provided but
444
+ doesn't include the time column. In that case, time values 1..T_planned
445
+ are crossed with plan factors to create expected cells.
446
+ If plan includes time column values, this parameter is ignored.
447
+
448
+ Returns
449
+ -------
450
+ SDSResult
451
+ SDS classification based on raw structure
452
+ """
453
+ # Build minimal structure view
454
+ structure_view, kt_cols = self._build_structure_view(df, spec, response_col)
455
+
456
+ # Compute N_kt = count of VALID responses per cell
457
+ # groupby naturally yields 0 for cells where ALL responses are NA
458
+ if kt_cols:
459
+ # GroupBy.count() is exactly `notna().sum()` per group, but runs in Cython.
460
+ # The previous `.apply(lambda s: s.notna().sum())` paid one Python round-trip
461
+ # per group, which dominated formulate — 73% of runtime at 1M rows with
462
+ # singleton cells (18.3s -> 0.06s here). Cells whose responses are ALL NA
463
+ # still yield 0, which is what ODS 4-6 detection depends on.
464
+ nkt_observed = structure_view.groupby(kt_cols, observed=True)[response_col].count()
465
+ else:
466
+ # No factors or time - single cell
467
+ # NOTE: This uses regular Index (not MultiIndex). _classify_by_nkt must tolerate both.
468
+ nkt_observed = pd.Series([structure_view[response_col].notna().sum()], index=[()])
469
+
470
+ # Validate we have some data
471
+ if nkt_observed.sum() == 0:
472
+ raise ValidationError('No valid response values found after filtering')
473
+
474
+ if plan is not None:
475
+ # WITH PLAN: Compare to canonicalized plan
476
+ canonicalized_plan = self._canonicalize_plan_values(plan, kt_cols)
477
+
478
+ # Handle time column:
479
+ # 1. If T_planned is given, use 1..T_planned
480
+ # 2. If plan includes time column, use that
481
+ # 3. Otherwise, use observed time values from structure_view
482
+ t = spec.time_var
483
+ if t and t in kt_cols and (t not in canonicalized_plan or not canonicalized_plan[t]):
484
+ if T_planned is not None:
485
+ # Use 1..T_planned
486
+ canonicalized_plan[t] = list(range(1, T_planned + 1))
487
+ else:
488
+ # Use observed time values from the data
489
+ observed_times = structure_view[t].dropna().unique().tolist()
490
+ canonicalized_plan[t] = sorted(observed_times)
491
+ logger.debug(
492
+ f"Plan doesn't include time column '{t}'; using observed time values: {canonicalized_plan[t]}"
493
+ )
494
+
495
+ # Check if we have all factor columns (not including time which we just filled)
496
+ factor_cols = [c for c in kt_cols if c != t]
497
+ missing_factor_cols = [c for c in factor_cols if c not in canonicalized_plan or not canonicalized_plan[c]]
498
+
499
+ if missing_factor_cols:
500
+ logger.warning(
501
+ f'Plan missing required factor columns or has empty values for {missing_factor_cols}; '
502
+ f'falling back to observed structure only.'
503
+ )
504
+ nkt_counts = nkt_observed
505
+ elif kt_cols:
506
+ # Build expected cells using from_product (deterministic, avoids tuple list)
507
+ planned_index = pd.MultiIndex.from_product([canonicalized_plan[c] for c in kt_cols], names=kt_cols)
508
+ nkt_counts = nkt_observed.reindex(planned_index, fill_value=0)
509
+ else:
510
+ # No kt columns - single cell
511
+ nkt_counts = nkt_observed
512
+ else:
513
+ # WITHOUT PLAN: classify over Bishop's full R = K×T grid — the cross of the
514
+ # distinct observed process-design conditions (K) with the distinct observed
515
+ # production times (T). Absent (condition, time) pairs become N_kt=0, matching
516
+ # Bishop's definition where the design state is defined over all R=K×T rational
517
+ # subgroups. Cells present but all-NA already appear as N_kt=0 via nkt_observed.
518
+ #
519
+ # The grid uses the COMPOSITE observed condition tuple (drop_duplicates on the
520
+ # factor columns), NOT the independent cross of each factor's levels — so a
521
+ # nested / non-crossed design is not over-flagged with impossible conditions.
522
+ #
523
+ # Only meaningful when there is both a condition dimension (K) and a time
524
+ # dimension (T); otherwise there is no K×T grid to form and observed cells
525
+ # already are the design.
526
+ k_vars = [c for c in kt_cols if c != spec.time_var]
527
+ t = spec.time_var
528
+ if k_vars and t and t in kt_cols:
529
+ conditions = structure_view[k_vars].drop_duplicates()
530
+ times = pd.DataFrame({t: sorted(structure_view[t].dropna().unique())})
531
+ full = conditions.merge(times, how='cross')
532
+ full_index = pd.MultiIndex.from_frame(full[kt_cols])
533
+ nkt_counts = nkt_observed.reindex(full_index, fill_value=0)
534
+ else:
535
+ nkt_counts = nkt_observed
536
+
537
+ # has_empty_cells is derived directly from N_kt distribution
538
+ n_empty_cells = int((nkt_counts == 0).sum())
539
+ has_empty_cells = n_empty_cells > 0
540
+
541
+ if has_empty_cells:
542
+ logger.debug(f'Structure detection: {n_empty_cells} cells with N_kt=0')
543
+
544
+ # Calculate min_cell_size for chart selection (only cells with valid data)
545
+ valid_nkt = nkt_counts[nkt_counts > 0]
546
+ min_cell_size = int(valid_nkt.min()) if len(valid_nkt) > 0 else 0
547
+
548
+ # Classify using standard logic
549
+ sds, reason = self._classify_by_nkt(nkt_counts, has_empty_cells)
550
+
551
+ # Include n_empty_cells in result for debugging/diagnostics
552
+ return SDSResult(sds=sds, min_cell_size=min_cell_size, reason=reason, n_empty_cells=n_empty_cells)
553
+
554
+ def detect_sds(
555
+ self,
556
+ df: pd.DataFrame,
557
+ spec: FormulationSpec,
558
+ plan: dict[str, list] | None = None,
559
+ T_planned: int | None = None,
560
+ response_col: str | None = None,
561
+ ) -> SDSResult:
562
+ """
563
+ Detect Sampling Design State from data structure.
564
+
565
+ This is a convenience wrapper around detect_sds_from_structure().
566
+
567
+ Parameters
568
+ ----------
569
+ df : DataFrame
570
+ Data (ideally raw with response NA rows preserved for accurate detection)
571
+ spec : FormulationSpec
572
+ Data preparation configuration
573
+ plan : dict, optional
574
+ Sampling plan specifying expected factor levels. Keys are column
575
+ names, values are lists of expected levels. When provided, enables
576
+ SDS 4-6 detection by comparing observed structure to planned
577
+ structure (detecting empty cells where N_kt = 0).
578
+
579
+ Mode 1 (plan=None): Classify based on observed data → SDS 1-3
580
+ Mode 2 (plan={...}): Compare to plan → enables SDS 4-6
581
+ T_planned : int, optional
582
+ Expected number of time points from sampling plan. When provided,
583
+ coverage calculation uses this instead of observed time count.
584
+ This enables detection of incomplete temporal coverage.
585
+ response_col : str, optional
586
+ Response column name. If not provided, uses spec.response_var.
587
+
588
+ Returns
589
+ -------
590
+ SDSResult
591
+ Result containing sds (1-6), min_cell_size, reason, and n_empty_cells.
592
+
593
+ Notes
594
+ -----
595
+ **Classification Logic (per Bishop Table 1)**
596
+
597
+ Complete/Semi-Complete (no empty cells):
598
+ - SDS 1: Min N_kt ≥ 2 (all cells replicated)
599
+ - SDS 2: Min = Max = 1 (all cells singleton)
600
+ - SDS 3: Min = 1, Max ≥ 2 (mixed)
601
+
602
+ Incomplete (has empty cells, requires plan):
603
+ - SDS 4: Has 0s, NO 1s, has ≥2s (no singletons)
604
+ - SDS 5: Has 0s, Max = 1 (no replication)
605
+ - SDS 6: Has 0s, has 1s, has ≥2s (mixed everything)
606
+
607
+ Examples
608
+ --------
609
+ >>> # Full replication: all cells have n≥2
610
+ >>> df = pd.DataFrame({
611
+ ... 'rsg': ['A', 'A', 'B', 'B'],
612
+ ... 'time': [1, 1, 1, 1],
613
+ ... 'y': [10, 11, 9, 10]
614
+ ... })
615
+ >>> result = detector.detect_sds(df, spec)
616
+ >>> result.sds
617
+ 1
618
+ >>> result.reason
619
+ 'full_replication'
620
+
621
+ >>> # With sampling plan (enables SDS 4-6 detection)
622
+ >>> plan = {'Lane': [1, 2, 3, 4], 'Phase': [1, 2, 3]}
623
+ >>> result = detector.detect_sds(df, spec, plan=plan)
624
+ """
625
+ resp = response_col if response_col is not None else spec.response_var
626
+
627
+ # Warn if data has no NA responses - likely passed prepared data
628
+ # Use _normalize_missing_tokens to detect NA (consistent with SDS detection)
629
+ # Guard against KeyError if column missing (will fail later with better error)
630
+ if resp in df.columns:
631
+ normalized_resp = self._normalize_missing_tokens(df[resp])
632
+ if normalized_resp.isna().sum() == 0:
633
+ logger.debug(
634
+ 'detect_sds called with data that has no NA responses; '
635
+ 'if this is prepared data, SDS may miss attempted-but-invalid cells.'
636
+ )
637
+
638
+ return self.detect_sds_from_structure(df, spec, resp, plan, T_planned)
639
+
640
+ def get_sds_characteristics(self, sds: int) -> dict:
641
+ """
642
+ Get detailed characteristics of an SDS.
643
+
644
+ Returns a dictionary describing the SDS properties, including:
645
+ - Human-readable description
646
+ - Replication type
647
+ - R2 calculation method
648
+ - Analysis capabilities
649
+ - Interaction analysis support
650
+
651
+ Parameters
652
+ ----------
653
+ sds : int
654
+ SDS number (0-6)
655
+
656
+ Returns
657
+ -------
658
+ dict
659
+ Dictionary with SDS characteristics
660
+
661
+ Examples
662
+ --------
663
+ >>> info = detector.get_sds_characteristics(1)
664
+ >>> info['description']
665
+ 'Full replication (all cells n≥2)'
666
+ >>> info['r2_method']
667
+ 'within_cell'
668
+ >>> info['capabilities']
669
+ ['full_vas', 'all_residuals', 'interactions', 'main_effects']
670
+ """
671
+ characteristics = {
672
+ 0: {
673
+ 'description': 'No analyzable observations after data cleaning',
674
+ 'replication_type': 'none',
675
+ 'r2_method': 'not_applicable',
676
+ 'capabilities': [],
677
+ 'interaction_analysis': False,
678
+ 'variance_decomposition': False,
679
+ },
680
+ 1: {
681
+ 'description': 'Full replication (all cells n≥2)',
682
+ 'replication_type': 'full',
683
+ 'r2_method': 'within_cell',
684
+ 'capabilities': ['full_vas', 'all_residuals', 'interactions', 'main_effects'],
685
+ 'interaction_analysis': True,
686
+ 'variance_decomposition': True,
687
+ },
688
+ 2: {
689
+ 'description': 'No replication (all cells n=1)',
690
+ 'replication_type': 'none',
691
+ 'r2_method': 'moving_average',
692
+ 'capabilities': ['all_residuals', 'limited_interactions', 'main_effects'],
693
+ 'interaction_analysis': 'limited',
694
+ 'variance_decomposition': True,
695
+ },
696
+ 3: {
697
+ 'description': 'Partial replication (mixed n=1 and n≥2)',
698
+ 'replication_type': 'partial',
699
+ 'r2_method': 'hybrid',
700
+ 'capabilities': ['all_residuals', 'partial_interactions', 'main_effects'],
701
+ 'interaction_analysis': 'partial',
702
+ 'variance_decomposition': True,
703
+ },
704
+ 4: {
705
+ 'description': 'Incomplete grid without singletons (has 0s and ≥2s, no 1s)',
706
+ 'replication_type': 'partial',
707
+ 'r2_method': 'exact', # All observed cells have replication
708
+ 'capabilities': ['full_vas', 'main_effects', 'stratification'],
709
+ 'interaction_analysis': False, # Incomplete grid limits this
710
+ 'variance_decomposition': True,
711
+ },
712
+ 5: {
713
+ 'description': 'Incomplete grid without replication (has 0s, max=1)',
714
+ 'replication_type': 'none',
715
+ 'r2_method': 'ma2', # No replication, use moving average
716
+ 'capabilities': ['partial_vas', 'stratification'],
717
+ 'interaction_analysis': False,
718
+ 'variance_decomposition': True, # VAS supported via moving average
719
+ },
720
+ 6: {
721
+ 'description': 'Incomplete grid with singletons (has 0s, 1s, and ≥2s)',
722
+ 'replication_type': 'partial',
723
+ 'r2_method': 'hybrid',
724
+ 'capabilities': ['partial_vas', 'main_effects', 'stratification'],
725
+ 'interaction_analysis': False,
726
+ 'variance_decomposition': True,
727
+ },
728
+ }
729
+
730
+ result = characteristics.get(sds, characteristics[0]).copy()
731
+ result['sds'] = sds
732
+
733
+ return result
734
+
735
+ def get_r2_method(self, stats: StructureStats) -> R2Method:
736
+ """
737
+ Determine R2 calculation method from observed structure.
738
+
739
+ This is the ONLY place where structure determines R2 method.
740
+ Do NOT use SDS labels here - use observed counts directly.
741
+
742
+ The R2 residual (within-cell variation) is the only VAS residual
743
+ whose calculation varies by structure. All other residuals (R1, R3,
744
+ R4, R5) are pure algebraic transformations once means are defined.
745
+
746
+ Parameters
747
+ ----------
748
+ stats : StructureStats
749
+ Observed structure statistics computed from data
750
+
751
+ Returns
752
+ -------
753
+ R2Method
754
+ 'exact' if all cells have replication (n_cell_min >= 2)
755
+ 'ma2' if all cells are singletons (n_cell_max == 1)
756
+ 'hybrid' for mixed replication (exact where n >= 2, MA2 where n = 1)
757
+
758
+ Notes
759
+ -----
760
+ Deterministic rule based on Bishop methodology:
761
+ - Eq 59 (exact): R2 = Y - Ȳ_kt, requires replication
762
+ - Eq 66 (MA2): R2 = (Y_j - Y_{j-1}) / 2, for singletons
763
+ - Hybrid: exact where replicated, MA2 where singleton
764
+ """
765
+ if stats.n_cell_min >= 2:
766
+ return 'exact'
767
+ elif stats.n_cell_max == 1:
768
+ return 'ma2'
769
+ else:
770
+ return 'hybrid'
771
+
772
+ def validate_sds_for_analysis(self, sds: int, analysis_type: str) -> bool:
773
+ """
774
+ Validate that SDS is appropriate for requested analysis.
775
+
776
+ Checks for known incompatibilities and logs helpful warnings.
777
+ Raises ValueError for fatal incompatibilities.
778
+
779
+ Parameters
780
+ ----------
781
+ sds : int
782
+ Detected SDS (1-6)
783
+ analysis_type : str
784
+ Requested analysis type ('Xbar', 'S', 'X', 'mR')
785
+
786
+ Returns
787
+ -------
788
+ bool
789
+ True if analysis can proceed
790
+
791
+ Raises
792
+ ------
793
+ ValueError
794
+ If SDS/analysis combination is invalid, with suggestion
795
+
796
+ Examples
797
+ --------
798
+ >>> # SDS 6 with Xbar - valid (incomplete grid with mixed replication)
799
+ >>> detector.validate_sds_for_analysis(sds=6, analysis_type='Xbar')
800
+ True
801
+
802
+ >>> # SDS 5 with Xbar - warning (no replication)
803
+ >>> detector.validate_sds_for_analysis(sds=5, analysis_type='Xbar')
804
+ # Logs warning about incomplete grid
805
+ True
806
+ """
807
+ # SDS 4 or 6: Incomplete grid
808
+ if sds in [4, 6]:
809
+ logger.info(
810
+ f'SDS {sds} detected: Incomplete grid.\nSome factor×time combinations are missing from the data.'
811
+ )
812
+
813
+ # SDS 5: Incomplete without replication
814
+ if sds == 5:
815
+ logger.warning(
816
+ 'SDS 5 detected: Incomplete grid without replication.\n'
817
+ 'Analysis results may be limited - no within-cell variance estimation.\n'
818
+ "Consider using 'X' analysis for this data structure."
819
+ )
820
+
821
+ return True
822
+
823
+ def should_calculate_vas_residuals(self, sds: int, analysis_type: str) -> bool:
824
+ """
825
+ Determine if VAS residual decomposition (R1-R5) should be calculated.
826
+
827
+ VAS residuals decompose total variation into components:
828
+ - R1: Total deviation from grand mean
829
+ - R2: Within-cell (unexplained) variation
830
+ - R3: Interaction effects (factor × time)
831
+ - R4: Time effects + unexplained
832
+ - R5: Factor effects + unexplained
833
+
834
+ **Calculate VAS when:**
835
+ 1. User requests Xbar or S chart (cell-level analysis)
836
+ 2. AND we have factorial structure (SDS 1-4, 6)
837
+
838
+ **Don't calculate VAS when:**
839
+ 1. User requests X or mR chart (individual-level analysis)
840
+ 2. OR SDS 5 (incomplete without replication - very limited structure)
841
+
842
+ Parameters
843
+ ----------
844
+ sds : int
845
+ Detected SDS (1-6)
846
+ analysis_type : str
847
+ Analysis type ('Xbar', 'S', 'X', 'mR')
848
+
849
+ Returns
850
+ -------
851
+ bool
852
+ True if VAS residuals should be calculated
853
+
854
+ Examples
855
+ --------
856
+ >>> # Xbar with SDS 1 - needs VAS
857
+ >>> detector.should_calculate_vas_residuals(sds=1, analysis_type='Xbar')
858
+ True
859
+
860
+ >>> # X with SDS 1 - doesn't need VAS (stratified charts)
861
+ >>> detector.should_calculate_vas_residuals(sds=1, analysis_type='X')
862
+ False
863
+ """
864
+ # SDS 5: Incomplete without replication - very limited analysis possible
865
+ if sds == 5:
866
+ logger.debug('No VAS: SDS 5 (incomplete without replication)')
867
+ return False
868
+
869
+ # X/mR use moving ranges, not factorial decomposition
870
+ # Grouping just creates separate stratified charts
871
+ if analysis_type in ['X', 'mR']:
872
+ logger.debug(
873
+ f'No VAS: {analysis_type} analysis uses moving ranges, '
874
+ f'not factorial decomposition. '
875
+ f'Grouping creates stratified charts (separate chart per group).'
876
+ )
877
+ return False
878
+
879
+ # Xbar/S with factorial structure (SDS 1-4, 6)
880
+ if sds in [1, 2, 3, 4, 6]:
881
+ logger.debug(f'Calculate VAS: SDS {sds} with {analysis_type} analysis supports decomposition')
882
+ return True
883
+
884
+ # Shouldn't reach here, but be conservative
885
+ logger.debug(f'No VAS: Unexpected case (SDS={sds})')
886
+ return False
887
+
888
+ # ========================================================================
889
+ # Private Helper Methods
890
+ # ========================================================================
891
+
892
+ def _normalize_missing_tokens(self, series: pd.Series) -> pd.Series:
893
+ """
894
+ Normalize missing value tokens to pd.NA without full type coercion.
895
+
896
+ This only handles:
897
+ - Standard missing tokens: *, NA, N/A, nan, null, None, empty string
898
+ - Whitespace-only strings
899
+
900
+ It does NOT:
901
+ - Coerce "10.5" to 10.5 (stays as string)
902
+ - Strip whitespace from non-missing values
903
+ - Detect/convert numeric strings
904
+
905
+ Use this for structure detection where we only need to identify NA responses,
906
+ not coerce types (that happens later in prepare_dataset()).
907
+
908
+ Parameters
909
+ ----------
910
+ series : pd.Series
911
+ Series to normalize
912
+
913
+ Returns
914
+ -------
915
+ pd.Series
916
+ Series with missing tokens converted to pd.NA
917
+ """
918
+ # Convert known missing tokens to NA (case-insensitive)
919
+ missing_tokens = {'*', 'na', 'n/a', 'nan', 'null', 'none'}
920
+
921
+ def normalize(x):
922
+ if pd.isna(x):
923
+ return pd.NA
924
+ if isinstance(x, str):
925
+ stripped = x.strip()
926
+ if stripped == '':
927
+ return pd.NA
928
+ if stripped.lower() in missing_tokens:
929
+ return pd.NA
930
+ return x
931
+
932
+ return series.apply(normalize)
933
+
934
+ def _build_structure_view(
935
+ self,
936
+ df: pd.DataFrame,
937
+ spec: FormulationSpec,
938
+ response_col: str,
939
+ ) -> tuple[pd.DataFrame, list[str]]:
940
+ """
941
+ Build minimal structure view for SDS detection.
942
+
943
+ This creates a lightweight projection of the data with:
944
+ - Only kt_cols + response_col
945
+ - Canonicalized kt columns (same type conversion as prepare_dataset)
946
+ - Normalized response column (missing tokens → NA)
947
+ - Filtered rows where kt columns have NA (can't determine cell membership)
948
+ - Response NA rows PRESERVED (reveals all-NA cells)
949
+
950
+ Parameters
951
+ ----------
952
+ df : DataFrame
953
+ Raw data (response NA rows NOT yet dropped)
954
+ spec : FormulationSpec
955
+ Data preparation configuration
956
+ response_col : str
957
+ Name of response column
958
+
959
+ Returns
960
+ -------
961
+ tuple of (structure_view, kt_cols)
962
+ structure_view: Canonicalized, filtered to valid kt rows, response NA preserved
963
+ kt_cols: List of column names for grouping
964
+ """
965
+ from .data_preparation import DataPreparation
966
+
967
+ prep = DataPreparation()
968
+
969
+ # Determine kt_cols
970
+ k_vars = spec.rsg_vars_list
971
+ t = spec.time_var
972
+ # Include time_var only if not already in rsg_vars (avoid duplicates)
973
+ kt_cols = k_vars + [t] if (t and t not in k_vars) else k_vars
974
+
975
+ # Project to needed columns only (deduplicate response_col if it's in kt_cols)
976
+ cols_needed = list(dict.fromkeys(kt_cols + [response_col])) # preserves order, removes dupes
977
+ out = df[cols_needed].copy()
978
+
979
+ # Canonicalize kt columns (same type conversion as prepare_dataset)
980
+ for col in kt_cols:
981
+ out[col], _ = prep._detect_and_convert_type(out[col], col)
982
+
983
+ # Normalize response column for consistent NA detection
984
+ # NOTE: This only normalizes missing tokens (*, NA, etc.) to pd.NA
985
+ # It does NOT do full type coercion (e.g., "10.5" stays "10.5", not 10.5)
986
+ # Full coercion happens later in prepare_dataset() for analysis
987
+ out[response_col] = self._normalize_missing_tokens(out[response_col])
988
+
989
+ # Filter rows with NA in any kt column (can't determine cell membership)
990
+ # Skip if kt_cols is empty (no factors, no time)
991
+ if kt_cols:
992
+ out = out[out[kt_cols].notna().all(axis=1)]
993
+
994
+ # NOTE: Response NA rows are PRESERVED - this reveals all-NA cells
995
+
996
+ return out, kt_cols
997
+
998
+ def _canonicalize_plan_values(
999
+ self,
1000
+ plan: dict[str, list],
1001
+ kt_cols: list[str],
1002
+ ) -> dict[str, list]:
1003
+ """
1004
+ Canonicalize plan values using the same type conversion as data.
1005
+
1006
+ This prevents false "missing cells" when plan has "1" but data became 1,
1007
+ or plan has "A " but data became "A".
1008
+
1009
+ Parameters
1010
+ ----------
1011
+ plan : dict
1012
+ Sampling plan with {column: [levels]} structure
1013
+ kt_cols : list[str]
1014
+ List of kt column names to canonicalize
1015
+
1016
+ Returns
1017
+ -------
1018
+ dict[str, list]
1019
+ Canonicalized plan with same types as data
1020
+ """
1021
+ from .data_preparation import DataPreparation
1022
+
1023
+ prep = DataPreparation()
1024
+ canonicalized = {}
1025
+
1026
+ for col in kt_cols:
1027
+ if col in plan:
1028
+ # Create a temporary Series and canonicalize it
1029
+ temp = pd.Series(plan[col])
1030
+ converted, _ = prep._detect_and_convert_type(temp, col)
1031
+ canonicalized[col] = converted.tolist()
1032
+ else:
1033
+ canonicalized[col] = []
1034
+
1035
+ return canonicalized
1036
+
1037
+ def classify_from_plan(self, K: int, T: int, N: int) -> SDSResult:
1038
+ """
1039
+ Compute the Plan Design State (PDS) from plan parameters.
1040
+
1041
+ PDS represents what the user *intended* to collect. Since a plan
1042
+ specifies all K×T cells with uniform N, the result is always
1043
+ SDS 1 (N >= 2) or SDS 2 (N == 1) — no empty cells, no mixed sizes.
1044
+
1045
+ Parameters
1046
+ ----------
1047
+ K : int
1048
+ Number of factor-level combinations (product of factor counts)
1049
+ T : int
1050
+ Planned number of time periods
1051
+ N : int
1052
+ Planned observations per cell
1053
+
1054
+ Returns
1055
+ -------
1056
+ SDSResult
1057
+ PDS classification with synthetic N_kt
1058
+ """
1059
+ import numpy as np
1060
+
1061
+ # Synthetic N_kt: all K×T cells exist with uniform N
1062
+ n_cells = K * T
1063
+ nkt_counts = pd.Series(np.full(n_cells, N))
1064
+ has_empty_cells = False
1065
+
1066
+ sds, reason = self._classify_by_nkt(nkt_counts, has_empty_cells)
1067
+ return SDSResult(sds=sds, min_cell_size=N, reason=reason, n_empty_cells=0)
1068
+
1069
+ def _classify_by_nkt(self, nkt_counts: pd.Series, has_empty_cells: bool) -> tuple[int, SDSReasonType]:
1070
+ """
1071
+ Classify SDS directly from N_kt distribution per Bishop Table 1.
1072
+
1073
+ This is the core classification logic - pure and simple.
1074
+
1075
+ Parameters
1076
+ ----------
1077
+ nkt_counts : pd.Series
1078
+ Observation counts for each (factor × time) cell that EXISTS in data
1079
+ has_empty_cells : bool
1080
+ Whether any expected cells are missing (N_kt = 0), determined by
1081
+ comparing to sampling plan
1082
+
1083
+ Returns
1084
+ -------
1085
+ tuple[int, SDSReasonType]
1086
+ (sds, reason) - SDS number and reason string
1087
+
1088
+ Notes
1089
+ -----
1090
+ Table 1 Classification:
1091
+
1092
+ Complete/Semi-Complete (no empty cells):
1093
+ - SDS 1: Min N_kt ≥ 2 (all replicated)
1094
+ - SDS 2: Min = Max = 1 (all singletons)
1095
+ - SDS 3: Min = 1, Max ≥ 2 (mixed)
1096
+
1097
+ Incomplete (has empty cells):
1098
+ - SDS 4: NO 1s, has ≥2s (no singletons)
1099
+ - SDS 5: Max = 1 (no replication)
1100
+ - SDS 6: Has 1s AND has ≥2s (mixed with singletons)
1101
+ """
1102
+ min_n = nkt_counts.min()
1103
+ max_n = nkt_counts.max()
1104
+ has_singletons = (nkt_counts == 1).any()
1105
+
1106
+ n_cells = len(nkt_counts)
1107
+ cells_with_n1 = (nkt_counts == 1).sum()
1108
+ cells_with_n2_plus = (nkt_counts >= 2).sum()
1109
+
1110
+ logger.debug(
1111
+ f'N_kt classification: {cells_with_n1} singletons, '
1112
+ f'{cells_with_n2_plus} replicated, has_empty_cells={has_empty_cells}'
1113
+ )
1114
+
1115
+ if not has_empty_cells:
1116
+ # Complete or Semi-Complete (SDS 1, 2, 3)
1117
+ if min_n >= 2:
1118
+ logger.debug(f'SDS 1: Complete - all cells replicated (min={min_n})')
1119
+ return (1, 'full_replication')
1120
+ elif max_n == 1:
1121
+ logger.debug('SDS 2: Semi-Complete - all cells singleton')
1122
+ return (2, 'no_replication')
1123
+ else:
1124
+ pct_replicated = 100 * cells_with_n2_plus / n_cells
1125
+ logger.debug(f'SDS 3: Semi-Complete - mixed ({pct_replicated:.0f}% replicated)')
1126
+ return (3, 'partial_replication')
1127
+ else:
1128
+ # Incomplete (SDS 4, 5, 6) - has empty cells
1129
+ if max_n == 1:
1130
+ logger.debug('SDS 5: Incomplete - no replication (max=1)')
1131
+ return (5, 'incomplete_no_replication')
1132
+ elif has_singletons:
1133
+ logger.debug(
1134
+ f'SDS 6: Incomplete with singletons ({cells_with_n1} singletons, {cells_with_n2_plus} replicated)'
1135
+ )
1136
+ return (6, 'incomplete_with_singletons')
1137
+ else:
1138
+ logger.debug(f'SDS 4: Incomplete without singletons ({cells_with_n2_plus} replicated cells)')
1139
+ return (4, 'incomplete_no_singletons')
1140
+
1141
+ def _calculate_coverage_ratio(
1142
+ self, df: pd.DataFrame, spec: FormulationSpec, plan: dict[str, list], T_planned: int | None = None
1143
+ ) -> float:
1144
+ """
1145
+ Calculate coverage ratio: observed cells / expected cells.
1146
+
1147
+ When a plan is provided, we can determine how complete the observed
1148
+ data grid is compared to what was intended.
1149
+
1150
+ Expected cells = K (factor combinations) × T (time points)
1151
+ Observed cells = Unique (factor, time) combinations in data
1152
+
1153
+ Parameters
1154
+ ----------
1155
+ df : DataFrame
1156
+ The analysis dataset
1157
+ spec : FormulationSpec
1158
+ Analysis specification
1159
+ plan : dict
1160
+ Sampling plan with {column: [levels]} structure.
1161
+ T_planned : int, optional
1162
+ Expected number of time points. If provided, used instead of
1163
+ observed time count for expected cell calculation.
1164
+
1165
+ Returns
1166
+ -------
1167
+ float
1168
+ Ratio of observed cells to expected cells (0.0 to 1.0).
1169
+ Only counts observed cells that match planned factor combinations,
1170
+ so extra levels in data don't inflate coverage.
1171
+
1172
+ Notes
1173
+ -----
1174
+ This implementation avoids cartesian product enumeration for scalability:
1175
+ - expected_K computed via math.prod (no enumeration)
1176
+ - Filtering uses per-column isin (not rsg string matching)
1177
+ """
1178
+ import math
1179
+
1180
+ import numpy as np
1181
+
1182
+ # Expected K without enumeration (scalable for large factor spaces)
1183
+ expected_K = math.prod(len(plan[var]) for var in spec.rsg_vars)
1184
+
1185
+ # Filter to planned rows using per-column isin (no cartesian product)
1186
+ mask = np.ones(len(df), dtype=bool)
1187
+ for var in spec.rsg_vars:
1188
+ mask &= df[var].isin(plan[var]).to_numpy()
1189
+ df_planned = df.loc[mask]
1190
+
1191
+ # Group by actual factor columns (not derived rsg string)
1192
+ # This is more explicit and avoids any encoding issues
1193
+ group_cols = spec.rsg_vars_list
1194
+ if spec.has_time:
1195
+ group_cols.append(spec.time_var)
1196
+ observed_T = df[spec.time_var].nunique(dropna=True)
1197
+ expected_T = T_planned if T_planned is not None else observed_T
1198
+ expected_R = expected_K * expected_T
1199
+ else:
1200
+ expected_R = expected_K
1201
+
1202
+ observed_R = df_planned.groupby(group_cols, dropna=True, observed=True).ngroups
1203
+
1204
+ # Safe edge case handling
1205
+ if expected_R == 0:
1206
+ return 1.0 if observed_R == 0 else 0.0
1207
+
1208
+ ratio = observed_R / expected_R
1209
+ logger.debug(f'Coverage: {observed_R}/{expected_R} = {ratio:.1%}')
1210
+ return ratio
1211
+
1212
+ # =========================================================================
1213
+ # SDS Analysis Plan Methods - Validation & Diagnostic Tools
1214
+ # =========================================================================
1215
+
1216
+ @staticmethod
1217
+ def get_analysis_plan(sds: int, min_cell_size: int = 0) -> SDSAnalysisPlan:
1218
+ """
1219
+ Get comprehensive analysis plan for an analytical Sampling Design State.
1220
+
1221
+ This is the authoritative specification of what the system will do
1222
+ when it encounters a particular SDS. Only analytical SDS (1-3) are
1223
+ supported — SDS 4-6 are observed/planned design states that always
1224
+ collapse to 1-3 after tidying (dropping NA response rows).
1225
+
1226
+ Parameters
1227
+ ----------
1228
+ sds : int
1229
+ Analytical Sampling Design State (1-3)
1230
+ min_cell_size : int, optional
1231
+ Actual minimum cell size from the data. If provided, enables
1232
+ data-driven R2 chart selection (R2_S when min_cell_size >= 2).
1233
+ Default is 0.
1234
+
1235
+ Returns
1236
+ -------
1237
+ SDSAnalysisPlan
1238
+ Complete specification of capabilities and limitations
1239
+
1240
+ Raises
1241
+ ------
1242
+ ValueError
1243
+ If sds is not in range 1-3
1244
+
1245
+ Examples
1246
+ --------
1247
+ >>> plan = SDSRegistry.get_analysis_plan(sds=1)
1248
+ >>> print(plan.name)
1249
+ 'Full Factorial with Complete Replication'
1250
+ >>> print(plan.vas_residuals_supported)
1251
+ True
1252
+ >>> print(plan.valid_charts)
1253
+ ['Xbar', 'S', 'mR', 'X', 'Histogram']
1254
+
1255
+ >>> # Check what your data structure supports
1256
+ >>> detector = SDSRegistry()
1257
+ >>> sds, min_n = detector.detect_sds(df, spec)
1258
+ >>> plan = detector.get_analysis_plan(sds, min_cell_size=min_n)
1259
+ >>> print(f"Your data supports: {', '.join(plan.valid_charts)}")
1260
+ """
1261
+ plans = {
1262
+ 1: SDSAnalysisPlan(
1263
+ sds=1,
1264
+ name='Full Factorial with Complete Replication',
1265
+ description='All factor × time cells have n ≥ 2 observations (best case for analysis)',
1266
+ has_factors=True,
1267
+ has_time=True,
1268
+ has_replication='full',
1269
+ valid_charts=['Histogram', 'Xbar', 'S', 'X', 'mR'],
1270
+ recommended_chart='Xbar',
1271
+ invalid_charts=[],
1272
+ vas_residuals_supported=True,
1273
+ residuals_available=['R1', 'R2', 'R3', 'R4', 'R5', 'R6'],
1274
+ residual_calculation_method='exact',
1275
+ main_effects_supported=True,
1276
+ interaction_effects_supported=True,
1277
+ supports_stratification=True,
1278
+ typical_use_cases=[
1279
+ 'Designed experiments with replication',
1280
+ 'Process capability studies',
1281
+ 'Multi-factor ANOVA-style analyses',
1282
+ 'Complete factorial designs',
1283
+ ],
1284
+ limitations=[],
1285
+ bishop_reference='Bishop Methodology: Complete Data (SDS 1)',
1286
+ ),
1287
+ 2: SDSAnalysisPlan(
1288
+ sds=2,
1289
+ name='Full Factorial with No Replication',
1290
+ description='All factor × time cells have exactly n = 1 observation',
1291
+ has_factors=True,
1292
+ has_time=True,
1293
+ has_replication='none',
1294
+ valid_charts=['Histogram', 'Xbar', 'S', 'X', 'mR'],
1295
+ recommended_chart='X',
1296
+ invalid_charts=[],
1297
+ vas_residuals_supported=True,
1298
+ residuals_available=['R1', 'R2', 'R3', 'R4', 'R5', 'R6'],
1299
+ residual_calculation_method='moving_average',
1300
+ main_effects_supported=True,
1301
+ interaction_effects_supported=True,
1302
+ supports_stratification=True,
1303
+ typical_use_cases=[
1304
+ 'Production data (one measurement per factor/time combination)',
1305
+ 'Unreplicated factorial experiments',
1306
+ 'Historical data analysis',
1307
+ 'Screening experiments',
1308
+ ],
1309
+ limitations=[
1310
+ 'R2 estimated via moving average (approximate, not exact)',
1311
+ 'Interaction confounded with pure error',
1312
+ ],
1313
+ bishop_reference='Bishop Methodology: No Replication (SDS 2)',
1314
+ ),
1315
+ 3: SDSAnalysisPlan(
1316
+ sds=3,
1317
+ name='Partial Replication',
1318
+ description='Mixed cells: some have n ≥ 2, others have n = 1',
1319
+ has_factors=True,
1320
+ has_time=True,
1321
+ has_replication='partial',
1322
+ valid_charts=['Histogram', 'Xbar', 'S', 'X', 'mR'],
1323
+ recommended_chart='X',
1324
+ invalid_charts=[],
1325
+ vas_residuals_supported=True,
1326
+ residuals_available=['R1', 'R2', 'R3', 'R4', 'R5', 'R6'],
1327
+ residual_calculation_method='hybrid',
1328
+ main_effects_supported=True,
1329
+ interaction_effects_supported=True,
1330
+ supports_stratification=True,
1331
+ typical_use_cases=[
1332
+ 'Unbalanced designs',
1333
+ 'Real-world data with missing observations',
1334
+ 'Opportunistic replication in some cells',
1335
+ 'Pilot studies with targeted replication',
1336
+ ],
1337
+ limitations=[
1338
+ 'R2 uses hybrid calculation (exact where possible, approximate elsewhere)',
1339
+ 'Variance estimates less precise than SDS 1',
1340
+ 'May have unequal subgroup sizes',
1341
+ ],
1342
+ bishop_reference='Bishop Methodology: Partial Replication (SDS 3)',
1343
+ ),
1344
+ # SDS 4-6 (incomplete grids) are observed/planned design states only.
1345
+ # After tidying (dropping NA response rows), they always collapse to
1346
+ # SDS 1-3: 4→1, 5→2, 6→3. The analytical design state (ADS) drives
1347
+ # analysis plans, so only 1-3 need entries here.
1348
+ }
1349
+
1350
+ if sds not in plans:
1351
+ raise ValueError(
1352
+ f'Invalid analytical SDS: {sds}. Must be 1-3. '
1353
+ f'SDS 4-6 are observed/planned design states that collapse to '
1354
+ f'1-3 after tidying. Available: {list(plans.keys())}'
1355
+ )
1356
+
1357
+ plan = plans[sds]
1358
+ # Set min_cell_size from actual data
1359
+ return replace(plan, min_cell_size=min_cell_size)
1360
+
1361
+ @staticmethod
1362
+ def print_all_analysis_plans() -> None:
1363
+ """
1364
+ Print comprehensive analysis plans for all analytical SDS (1-3).
1365
+
1366
+ This generates a complete reference guide showing what the system
1367
+ will do for each Sampling Design State. Useful for:
1368
+ - Validating implementation against Bishop's methodology
1369
+ - Documentation generation
1370
+ - Training and education
1371
+ - Understanding system capabilities
1372
+
1373
+ Note: SDS 4-6 are observed/planned design states that collapse
1374
+ to 1-3 after tidying. Only analytical SDS have analysis plans.
1375
+
1376
+ Examples
1377
+ --------
1378
+ >>> SDSRegistry.print_all_analysis_plans()
1379
+ # Prints complete analysis plan for SDS 1-3
1380
+ """
1381
+ print('=' * 70)
1382
+ print('SAMPLING DESIGN STATE (SDS) ANALYSIS PLANS')
1383
+ print('Complete Specification of System Capabilities')
1384
+ print('=' * 70)
1385
+ print()
1386
+
1387
+ for sds in range(1, 4): # Analytical SDS 1-3
1388
+ plan = SDSRegistry.get_analysis_plan(sds)
1389
+ print(plan)
1390
+ print()
1391
+ print()
1392
+
1393
+ @staticmethod
1394
+ def get_capability_matrix() -> pd.DataFrame:
1395
+ """
1396
+ Generate a comparison matrix of all SDS capabilities.
1397
+
1398
+ Returns a DataFrame showing what each SDS supports, useful for
1399
+ quick reference and validation.
1400
+
1401
+ Returns
1402
+ -------
1403
+ pd.DataFrame
1404
+ Matrix with SDS as index and capabilities as columns
1405
+
1406
+ Examples
1407
+ --------
1408
+ >>> matrix = SDSRegistry.get_capability_matrix()
1409
+ >>> print(matrix)
1410
+ >>> matrix.to_excel('sds_capabilities.xlsx')
1411
+ """
1412
+ data = []
1413
+ for sds in range(1, 4): # Analytical SDS 1-3
1414
+ plan = SDSRegistry.get_analysis_plan(sds)
1415
+ data.append(
1416
+ {
1417
+ 'SDS': sds,
1418
+ 'Name': plan.name,
1419
+ 'Factors': '✓' if plan.has_factors else '✗',
1420
+ 'Time': '✓' if plan.has_time else '✗',
1421
+ 'Replication': plan.has_replication,
1422
+ 'Valid Charts': ', '.join(plan.valid_charts),
1423
+ 'Recommended': plan.recommended_chart,
1424
+ 'VAS': '✓' if plan.vas_residuals_supported else '✗',
1425
+ 'R2 Method': plan.residual_calculation_method,
1426
+ 'Main Effects': '✓' if plan.main_effects_supported else '✗',
1427
+ 'Interactions': '✓' if plan.interaction_effects_supported else '✗',
1428
+ 'Stratification': '✓' if plan.supports_stratification else '✗',
1429
+ }
1430
+ )
1431
+
1432
+ return pd.DataFrame(data).set_index('SDS')