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,361 @@
1
+ from __future__ import annotations
2
+
3
+ import logging
4
+
5
+ import pandas as pd
6
+
7
+ from .data_preparation import DataPreparation
8
+ from .effects_calculator import EffectsCalculator
9
+ from .formulation_spec import FormulationSpec
10
+ from .residual_calculator import calculate_vas_residuals
11
+ from .sds_detector import SDSRegistry, SDSResult, StructureStats
12
+
13
+ # Configure module logger
14
+ logger = logging.getLogger(__name__)
15
+
16
+
17
+ class AnalysisDataSet:
18
+ """
19
+ Orchestrates statistical process control analysis using Bishop's VAS methodology.
20
+
21
+ This class coordinates the workflow:
22
+ 1. Data preparation and validation
23
+ 2. VAS residual calculation (R1-R5) based on provided SDS
24
+ 3. Effects and interactions analysis
25
+
26
+ SDS (Sampling Design State) is required and must be detected at the entry
27
+ point (ProcessBehavior) before creating an AnalysisDataSet. This ensures
28
+ SDS is detected exactly once per workflow.
29
+
30
+ Uses composition pattern - delegates to focused classes for each concern.
31
+ """
32
+
33
+ def __init__(self, df: pd.DataFrame, spec: FormulationSpec, observed_sds: int):
34
+ """
35
+ Initialize analysis with data and specification.
36
+
37
+ Parameters
38
+ ----------
39
+ df : pd.DataFrame
40
+ Raw input data
41
+ spec : FormulationSpec
42
+ Structural configuration for the analysis
43
+ observed_sds : int
44
+ Observed Design State detected on raw data (ODS). This is the
45
+ SDS detected at the entry point (ProcessBehavior) on raw data
46
+ before NA filtering. Used for diagnostic logging.
47
+ The Analytical Design State (ADS) is computed internally on
48
+ tidy data and drives analysis decisions.
49
+ """
50
+ # Store inputs
51
+ self.spec = spec
52
+ self.observed_design_state: int = observed_sds
53
+
54
+ # Initialize output containers
55
+ self.interactions = {}
56
+ self.effects = {}
57
+
58
+ # Structure stats (computed once in _initialize)
59
+ self._structure_stats: StructureStats | None = None
60
+ self._n_per_cell: pd.Series | None = None
61
+ self._ybar_kt: pd.Series | None = None
62
+
63
+ # ADS result (computed in _initialize on tidy data)
64
+ self._ads_result: SDSResult | None = None
65
+
66
+ # Composition - each component has one job (Single Responsibility Principle)
67
+ # SDS detection was done on raw data by ProcessBehavior. Here we only use
68
+ # SDSRegistry for characteristic lookups and R2 method selection on tidy
69
+ # structure. When the tidy-state concept is formalized, its classification
70
+ # would live here alongside SDSRegistry.
71
+ self._prep = DataPreparation()
72
+ self._sds_registry = SDSRegistry()
73
+ self._effects_calc = EffectsCalculator()
74
+
75
+ # Run the analysis workflow
76
+ self._initialize(df)
77
+
78
+ def _initialize(self, raw_df: pd.DataFrame):
79
+ """
80
+ Execute the analysis workflow.
81
+
82
+ Clear orchestration that reads like a recipe:
83
+ 1. Validate and prepare data
84
+ 2. Apply SDS (passed from entry point)
85
+ 3. Compute structure stats on tidy data
86
+ 4. Calculate VAS residuals (if appropriate for SDS)
87
+ 5. Calculate effects and interactions (if appropriate)
88
+ """
89
+ # Step 1: Validate and prepare data
90
+ logger.debug('Preparing dataset')
91
+ self._prep.validate_columns(raw_df, self.spec)
92
+ self.analysis_dataset = self._prep.prepare_dataset(raw_df, self.spec)
93
+ self.analysis_dataset = self._prep.build_keys(self.analysis_dataset, self.spec)
94
+
95
+ # Step 2: Use the provided ODS (detected at entry point on raw data)
96
+ logger.debug(f'Using ODS: {self.observed_design_state}')
97
+ self.raw_sds_characteristics = self._sds_registry.get_sds_characteristics(self.observed_design_state)
98
+ logger.debug(f'ODS {self.observed_design_state} - {self.raw_sds_characteristics["description"]}')
99
+
100
+ # Step 3: Compute structure stats once on tidy data (single source of truth)
101
+ # This enables R2 method selection and availability checks.
102
+ # The tidy structure may differ from the raw SDS — e.g., SDS 3 (partial
103
+ # replication) on raw data can become structurally equivalent to SDS 1
104
+ # (full replication) after NA rows are dropped.
105
+ self._compute_structure_stats()
106
+
107
+ # Step 3b: Compute Analytical Design State (ADS) on tidy data
108
+ self._compute_ads()
109
+
110
+ # Step 4: Calculate VAS residuals when we have both grouping AND time
111
+ # (need Ybar_k for factor effects and Ybar_t for time effects)
112
+ needs_residuals = self.spec.has_grouping and self.spec.has_time
113
+
114
+ if needs_residuals:
115
+ logger.debug('Calculating VAS residuals (R1-R5)')
116
+
117
+ # _compute_structure_stats() ran above; structure_stats is populated.
118
+ if self._structure_stats is None:
119
+ raise RuntimeError('_structure_stats not populated; _compute_structure_stats must run first')
120
+ r2_method = self._sds_registry.get_r2_method(self._structure_stats)
121
+ logger.debug(f'Using R2 method: {r2_method}')
122
+
123
+ self.analysis_dataset = calculate_vas_residuals(
124
+ self.analysis_dataset,
125
+ self.spec,
126
+ r2_method,
127
+ n_per_cell=self._n_per_cell,
128
+ ybar_kt=self._ybar_kt,
129
+ )
130
+
131
+ # Calculate centered residuals
132
+ self._calculate_centered_residuals()
133
+
134
+ # Step 5: Calculate effects and interactions
135
+ # ADS drives method selection (not ODS)
136
+ logger.debug('Calculating effects and interactions')
137
+ self.effects = self._effects_calc.calculate_all_effects(self.analysis_dataset, self.spec)
138
+ self.interactions = self._effects_calc.calculate_interactions(
139
+ self.analysis_dataset, self.spec, self._ads_result.sds, effects=self.effects
140
+ )
141
+ else:
142
+ logger.debug(
143
+ f'Skipping VAS residuals: requires both grouping and time. '
144
+ f'has_grouping={self.spec.has_grouping}, has_time={self.spec.has_time}'
145
+ )
146
+
147
+ # Log final analysis summary (after all calculations are complete)
148
+ logger.debug(self.analysis_summary)
149
+
150
+ # =========================================================================
151
+ # Properties (for backward compatibility and convenience)
152
+ # =========================================================================
153
+
154
+ @property
155
+ def has_vas_residuals(self) -> bool:
156
+ """Check if VAS residuals were calculated."""
157
+ return 'R1' in self.analysis_dataset.columns
158
+
159
+ @property
160
+ def structure_stats(self) -> StructureStats | None:
161
+ """Get structure statistics (computed once during initialization)."""
162
+ return self._structure_stats
163
+
164
+ @property
165
+ def n_per_cell(self) -> pd.Series | None:
166
+ """Get observation counts per cell_key (computed once during initialization)."""
167
+ return self._n_per_cell
168
+
169
+ @property
170
+ def tidy_structure_summary(self) -> dict | None:
171
+ """
172
+ Summary of the tidy data structure after NA drops and preparation.
173
+
174
+ The tidy structure drives R2 method selection and may differ from
175
+ what the raw SDS (detected on uncleansed data) would predict.
176
+ For example, SDS 3 (partial replication) on raw data can become
177
+ structurally equivalent to full replication after tidying.
178
+
179
+ Returns
180
+ -------
181
+ dict or None
182
+ Dictionary with tidy structure details:
183
+ - n_cell_min: minimum observations per cell
184
+ - n_cell_max: maximum observations per cell
185
+ - K_obs: number of unique factor levels observed
186
+ - r2_method: 'exact', 'ma2', or 'hybrid'
187
+ Returns None if structure stats haven't been computed.
188
+ """
189
+ if self._structure_stats is None:
190
+ return None
191
+ return {
192
+ 'n_cell_min': self._structure_stats.n_cell_min,
193
+ 'n_cell_max': self._structure_stats.n_cell_max,
194
+ 'K_obs': self._structure_stats.K_obs,
195
+ 'r2_method': self._sds_registry.get_r2_method(self._structure_stats),
196
+ }
197
+
198
+ def _compute_structure_stats(self) -> None:
199
+ """
200
+ Compute and cache structure statistics once.
201
+
202
+ This is the single source of truth for:
203
+ - n_per_cell: observation counts per cell_key
204
+ - structure_stats: StructureStats with min/max cell sizes
205
+
206
+ These drive R2 method selection and availability checks.
207
+ """
208
+ df = self.analysis_dataset
209
+ y = self.spec.response_var
210
+
211
+ # Compute n_per_cell and cell means once
212
+ self._n_per_cell = df.groupby('cell_key', observed=True)[y].transform('size')
213
+ self._ybar_kt = df.groupby('cell_key', observed=True)[y].transform('mean')
214
+
215
+ # Handle edge case: empty data or all NaN
216
+ if len(self._n_per_cell) == 0 or self._n_per_cell.isna().all():
217
+ n_cell_min = 0
218
+ n_cell_max = 0
219
+ else:
220
+ n_cell_min = int(self._n_per_cell.min())
221
+ n_cell_max = int(self._n_per_cell.max())
222
+
223
+ # Compute structure stats
224
+ self._structure_stats = StructureStats(
225
+ has_grouping=bool(self.spec.rsg_vars),
226
+ has_order='obs_id' in df.columns,
227
+ n_cell_min=n_cell_min,
228
+ n_cell_max=n_cell_max,
229
+ K_obs=df['rsg_key'].nunique() if 'rsg_key' in df.columns else 0,
230
+ )
231
+
232
+ r2_method = self._sds_registry.get_r2_method(self._structure_stats)
233
+
234
+ logger.debug(
235
+ f'Tidy structure: n_cell_min={n_cell_min}, '
236
+ f'n_cell_max={n_cell_max}, '
237
+ f'K_obs={self._structure_stats.K_obs}, '
238
+ f'effective R2 method: {r2_method}'
239
+ )
240
+
241
+ def _compute_ads(self) -> None:
242
+ """
243
+ Compute Analytical Design State (ADS) on tidy data.
244
+
245
+ The ADS reflects the structure of data that is fit for analysis,
246
+ after NA filtering and tidying. It may differ from the ODS when
247
+ tidying removes empty or singleton cells.
248
+ """
249
+ df = self.analysis_dataset
250
+ y = self.spec.response_var
251
+
252
+ # Compute N_kt from tidy data grouped by cell_key
253
+ if 'cell_key' in df.columns and len(df) > 0:
254
+ tidy_nkt = df.groupby('cell_key', observed=True)[y].size()
255
+ else:
256
+ tidy_nkt = pd.Series(dtype=int)
257
+
258
+ # Guard: empty tidy data → ADS 0
259
+ if len(tidy_nkt) == 0:
260
+ self._ads_result = SDSResult(sds=0, reason=None, min_cell_size=0, n_empty_cells=0)
261
+ logger.debug('ADS 0: no valid observations after tidying')
262
+ return
263
+
264
+ has_empty_cells = (tidy_nkt == 0).any()
265
+ n_empty = int((tidy_nkt == 0).sum())
266
+ valid_nkt = tidy_nkt[tidy_nkt > 0]
267
+ min_cell_size = int(valid_nkt.min()) if len(valid_nkt) > 0 else 0
268
+
269
+ sds, reason = self._sds_registry._classify_by_nkt(tidy_nkt, has_empty_cells)
270
+ self._ads_result = SDSResult(sds=sds, min_cell_size=min_cell_size, reason=reason, n_empty_cells=n_empty)
271
+
272
+ if self._ads_result.sds != self.observed_design_state:
273
+ logger.info(
274
+ f'Design state drift: ODS {self.observed_design_state} → '
275
+ f'ADS {self._ads_result.sds} ({self._ads_result.reason})'
276
+ )
277
+ else:
278
+ logger.debug(f'ADS {self._ads_result.sds} ({self._ads_result.reason})')
279
+
280
+ @property
281
+ def analytical_design_state(self) -> SDSResult:
282
+ """
283
+ Analytical Design State (ADS) computed on tidy data.
284
+
285
+ The ADS drives analysis decisions: valid charts, residual
286
+ availability, R2 method, and interaction method selection.
287
+ """
288
+ if self._ads_result is None:
289
+ raise RuntimeError('_ads_result not populated; __init__ must complete first')
290
+ return self._ads_result
291
+
292
+ @property
293
+ def analysis_summary(self) -> dict:
294
+ """
295
+ Get comprehensive summary of the analysis dataset.
296
+
297
+ Returns
298
+ -------
299
+ dict
300
+ Dictionary with analysis metadata including:
301
+ - sds: Detected sampling design state
302
+ - sds_info: Full SDS characteristics
303
+ - has_vas: Whether VAS residuals calculated
304
+ - n_observations: Total observations
305
+ - analysis_type: Type of analysis being performed
306
+ """
307
+ summary = {
308
+ 'observed_sds': self.observed_design_state,
309
+ 'analytical_sds': self._ads_result.sds if self._ads_result else self.observed_design_state,
310
+ 'sds_info': self.raw_sds_characteristics,
311
+ 'has_vas_residuals': self.has_vas_residuals,
312
+ 'n_observations': len(self.analysis_dataset),
313
+ }
314
+ return summary
315
+
316
+ # =========================================================================
317
+ # Centered Residuals
318
+ # =========================================================================
319
+
320
+ def _calculate_centered_residuals(self):
321
+ """
322
+ Calculate centered residuals (Rbar and RCR values).
323
+
324
+ These calculations center residuals by their means and reconstruct
325
+ Y from variance components to verify decomposition correctness.
326
+
327
+ Note: This method intentionally mutates self.analysis_dataset in-place
328
+ (adding columns directly to the DataFrame). This differs from
329
+ calculate_vas_residuals() which returns a new DataFrame.
330
+ The mutation is safe here because this is called once
331
+ during __init__ as part of the analysis pipeline, and the columns
332
+ added (Rbar_kt, Rbar_k, Rbar_t, RCR1-RCR5) are outputs of the
333
+ analysis that become part of the dataset.
334
+
335
+ Calculates:
336
+ - Rbar_kt: Mean of R1 per cell (factor x time)
337
+ - Rbar_k: Mean of R1 per factor level
338
+ - Rbar_t: Mean of R1 per time point
339
+ - RCR1-RCR5: Reconstructed Y values from centered residuals
340
+ """
341
+ if not self.spec.has_grouping:
342
+ return
343
+
344
+ df = self.analysis_dataset
345
+
346
+ # Calculate centered residual means
347
+ rsg_time_groups = df.groupby([self.spec.rsg_var_name, self.spec.time_var], observed=True)
348
+ df['Rbar_kt'] = rsg_time_groups['R1'].transform('mean')
349
+ df['Rbar_k'] = df.groupby([self.spec.rsg_var_name], observed=True)['R1'].transform('mean')
350
+ df['Rbar_t'] = df.groupby([self.spec.time_var], observed=True)['R1'].transform('mean')
351
+
352
+ # Calculate RCR (Reconstructed Centered Residuals)
353
+ # These verify that Y can be reconstructed from components
354
+ df['RCR1'] = df['Ybar'] + df['R1'] # Y = Ybar + R1
355
+ df['RCR2'] = df['Ybar_kt'] + df['R2'] # Y = Ybar_kt + R2
356
+ # Y = (Ybar_k + Ybar_t - Ybar) + R3
357
+ df['RCR3'] = (df['Ybar_k'] + df['Ybar_t'] - df['Ybar']) + df['R3']
358
+ # Y = (Ybar + Ybar_kt - Ybar_t) + R4
359
+ df['RCR4'] = (df['Ybar'] + df['Ybar_kt'] - df['Ybar_t']) + df['R4']
360
+ # Y = (Ybar + Ybar_kt - Ybar_k) + R5
361
+ df['RCR5'] = (df['Ybar'] + df['Ybar_kt'] - df['Ybar_k']) + df['R5']