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.
- processbehavior/__init__.py +133 -0
- processbehavior/analysis.py +2944 -0
- processbehavior/analysis_dataset.py +361 -0
- processbehavior/analysis_result.py +1276 -0
- processbehavior/calibration.py +70 -0
- processbehavior/capability.py +720 -0
- processbehavior/data_preparation.py +792 -0
- processbehavior/datasets/__init__.py +37 -0
- processbehavior/datasets/data/coffee_shop_demo_long.csv +385 -0
- processbehavior/datasets/loaders.py +66 -0
- processbehavior/datasets/synthetic.py +1711 -0
- processbehavior/derivations.py +602 -0
- processbehavior/effects_calculator.py +603 -0
- processbehavior/exceptions.py +216 -0
- processbehavior/exporters/__init__.py +5 -0
- processbehavior/exporters/excel_exporter.py +708 -0
- processbehavior/focused_result.py +329 -0
- processbehavior/formulation_spec.py +144 -0
- processbehavior/loss_function.py +517 -0
- processbehavior/maximum_information.py +285 -0
- processbehavior/plotting/__init__.py +46 -0
- processbehavior/plotting/_math.py +121 -0
- processbehavior/plotting/capability_chart.py +858 -0
- processbehavior/plotting/contracts.py +176 -0
- processbehavior/plotting/control_chart.py +206 -0
- processbehavior/plotting/effects_charts.py +640 -0
- processbehavior/plotting/lane_boundaries.py +78 -0
- processbehavior/plotting/limits.py +157 -0
- processbehavior/plotting/maximum_information_chart.py +395 -0
- processbehavior/plotting/plotter.py +1469 -0
- processbehavior/plotting/renderers.py +660 -0
- processbehavior/plotting/report.py +211 -0
- processbehavior/plotting/residuals.py +282 -0
- processbehavior/plotting/run_rules_viz.py +150 -0
- processbehavior/plotting/stats_box.py +183 -0
- processbehavior/plotting/themes.py +544 -0
- processbehavior/plotting/x_axis_layout.py +474 -0
- processbehavior/plotting/zones.py +127 -0
- processbehavior/process_behavior.py +1290 -0
- processbehavior/py.typed +0 -0
- processbehavior/residual_calculator.py +572 -0
- processbehavior/result_signals.py +149 -0
- processbehavior/result_tabular.py +296 -0
- processbehavior/sds_detector.py +1432 -0
- processbehavior/signals/__init__.py +14 -0
- processbehavior/signals/config.py +435 -0
- processbehavior/signals/detector.py +328 -0
- processbehavior/signals/detectors.py +368 -0
- processbehavior/signals/result.py +232 -0
- processbehavior/spc_constants.py +692 -0
- processbehavior/study.py +2733 -0
- processbehavior/types.py +154 -0
- processbehavior-0.2.0.dist-info/METADATA +279 -0
- processbehavior-0.2.0.dist-info/RECORD +56 -0
- processbehavior-0.2.0.dist-info/WHEEL +4 -0
- 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')
|