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,328 @@
1
+ """
2
+ Main signal detection engine.
3
+
4
+ Orchestrates rule detection and builds comprehensive results.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import logging
10
+ from typing import TYPE_CHECKING
11
+
12
+ import pandas as pd
13
+
14
+ from ..exceptions import ValidationError
15
+ from .config import SignalConfig
16
+ from .detectors import (
17
+ detect_avoiding_center,
18
+ detect_beyond_limits,
19
+ detect_oscillation,
20
+ detect_reduced_variation,
21
+ detect_run,
22
+ detect_trend,
23
+ detect_zone_a_2_of_3,
24
+ detect_zone_b_4_of_5,
25
+ )
26
+ from .result import SignalResult
27
+
28
+ if TYPE_CHECKING:
29
+ pass
30
+
31
+ logger = logging.getLogger(__name__)
32
+
33
+
34
+ class SignalDetector:
35
+ """
36
+ Main signal detection engine.
37
+
38
+ Applies Western Electric rules to control chart data and returns
39
+ comprehensive violation information.
40
+
41
+ Examples
42
+ --------
43
+ >>> detector = SignalDetector()
44
+ >>> signals = detector.detect(data, stats, config)
45
+ """
46
+
47
+ # Rule mapping
48
+ RULE_DETECTORS = {
49
+ 'rule_1': detect_beyond_limits,
50
+ 'rule_2': detect_zone_a_2_of_3,
51
+ 'rule_3': detect_zone_b_4_of_5,
52
+ 'rule_4': detect_run,
53
+ 'rule_5': detect_trend,
54
+ 'rule_6': detect_oscillation,
55
+ 'rule_7': detect_reduced_variation,
56
+ 'rule_8': detect_avoiding_center,
57
+ }
58
+
59
+ RULE_DESCRIPTIONS = {
60
+ 'rule_1': 'Point beyond control limits',
61
+ 'rule_2': '2 of 3 consecutive points in Zone A',
62
+ 'rule_3': '4 of 5 consecutive points in Zone B or beyond',
63
+ 'rule_4': '8+ consecutive points on same side of center',
64
+ 'rule_5': '6+ consecutive points trending',
65
+ 'rule_6': '14+ consecutive points alternating',
66
+ 'rule_7': '15+ consecutive points in Zone C',
67
+ 'rule_8': '8+ consecutive points avoiding Zone C',
68
+ }
69
+
70
+ def detect(
71
+ self,
72
+ data: pd.DataFrame,
73
+ stats: dict,
74
+ config: SignalConfig | None = None,
75
+ value_col: str = 'mean',
76
+ chart_name: str = 'Chart',
77
+ chart_type: str = 'Xbar',
78
+ ) -> SignalResult:
79
+ """
80
+ Detect signals in control chart data.
81
+
82
+ Parameters
83
+ ----------
84
+ data : DataFrame
85
+ Chart data with observations
86
+ stats : dict
87
+ Chart statistics (upl, lpl, center, etc.)
88
+ config : SignalConfig, optional
89
+ Detection configuration (uses defaults if None)
90
+ value_col : str, default 'mean'
91
+ Name of value column
92
+ chart_name : str, default 'Chart'
93
+ Name of chart for reporting
94
+ chart_type : str, default 'Xbar'
95
+ Chart type ('Xbar', 'S', 'X', 'mR') - determines applicable rules
96
+
97
+ Returns
98
+ -------
99
+ SignalResult
100
+ Comprehensive signal detection results
101
+
102
+ Examples
103
+ --------
104
+ >>> detector = SignalDetector()
105
+ >>> signals = detector.detect(chart_data, chart_stats, chart_type='X')
106
+ >>> print(signals.summary)
107
+ """
108
+ config = config or SignalConfig()
109
+
110
+ # Get applicable rules for this chart type
111
+ applicable_rules = config.get_rules_for_chart(chart_type)
112
+
113
+ # Log rule selection
114
+ if logger.isEnabledFor(logging.INFO):
115
+ all_rules = [f'rule_{i}' for i in range(1, 9)]
116
+ skipped_rules = [r for r in all_rules if r not in applicable_rules]
117
+ if skipped_rules:
118
+ logger.info(
119
+ f"Chart '{chart_name}' (type: {chart_type}): "
120
+ f'Applying rules {applicable_rules}. '
121
+ f'Skipped {skipped_rules} (not applicable for {chart_type} charts)'
122
+ )
123
+ else:
124
+ logger.info(f"Chart '{chart_name}' (type: {chart_type}): Applying all rules {applicable_rules}")
125
+
126
+ # Validate inputs (empty data / missing stats only). Per-rule observation
127
+ # minimums are enforced below by skipping the rules that can't run — a small
128
+ # group still gets the rules it supports, rather than aborting the whole call.
129
+ self._validate_inputs(data, stats)
130
+
131
+ # Apply filtering
132
+ filtered_data = self._filter_data(data, config)
133
+
134
+ # Detect if limits vary
135
+ limits_vary = self._limits_vary(stats)
136
+
137
+ # Calculate zones (constant or per-row)
138
+ if limits_vary:
139
+ # Use per-row limits from data
140
+ zones = self._calculate_per_row_zones(filtered_data, stats, config)
141
+ else:
142
+ # Use constant limits from stats
143
+ center = stats['center']
144
+ sigma = (stats['upl'] - center) / 3
145
+ zones = config.zone_definition.get_boundaries(center, sigma)
146
+
147
+ # Detect violations for each applicable rule
148
+ all_violations = pd.DataFrame(index=filtered_data.index)
149
+ # Applicable rules skipped for too few observations (rule -> reason), so the
150
+ # result can report partial evaluation instead of raising.
151
+ rules_skipped: dict[str, str] = {}
152
+
153
+ for rule_name in applicable_rules:
154
+ if rule_name not in self.RULE_DETECTORS:
155
+ logger.warning(f'Unknown rule: {rule_name}, skipping')
156
+ continue
157
+
158
+ # Check minimum observations — skip (don't abort) rules the group is too small for.
159
+ min_obs = self._get_min_observations(rule_name)
160
+ if len(filtered_data) < min_obs:
161
+ logger.debug(
162
+ f'Skipping {rule_name}: insufficient observations (need {min_obs}, have {len(filtered_data)})'
163
+ )
164
+ rules_skipped[rule_name] = f'needs {min_obs} observations, have {len(filtered_data)}'
165
+ continue
166
+
167
+ # Apply detector
168
+ detector = self.RULE_DETECTORS[rule_name]
169
+
170
+ try:
171
+ if rule_name in ['rule_2', 'rule_3', 'rule_7', 'rule_8']:
172
+ # Zone-based rules
173
+ violations = detector(filtered_data, stats, value_col, zones, limits_vary)
174
+ elif rule_name == 'rule_1':
175
+ # Rule 1 can use per-row limits
176
+ violations = detector(filtered_data, stats, value_col, limits_vary)
177
+ else:
178
+ # Other rules (4, 5, 6) don't use zones
179
+ violations = detector(filtered_data, stats, value_col)
180
+
181
+ all_violations[rule_name] = violations
182
+
183
+ except Exception as e:
184
+ logger.error(f'Error detecting {rule_name}: {e}')
185
+ all_violations[rule_name] = False
186
+
187
+ # Build result
188
+ return self._build_result(
189
+ data=filtered_data, violations=all_violations, stats=stats, value_col=value_col,
190
+ chart_name=chart_name, rules_skipped=rules_skipped,
191
+ )
192
+
193
+ def _validate_inputs(self, data: pd.DataFrame, stats: dict):
194
+ """Validate inputs and provide helpful errors.
195
+
196
+ Only genuinely-unrecoverable conditions raise here (empty data, missing
197
+ limit statistics). Too-few-observations is NOT an error — the caller skips
198
+ the rules that need more points and reports partial evaluation.
199
+ """
200
+ if data.empty:
201
+ raise ValidationError('Cannot detect signals on empty DataFrame')
202
+
203
+ required_stats = ['upl', 'lpl', 'center']
204
+ missing = [s for s in required_stats if s not in stats]
205
+ if missing:
206
+ raise ValidationError(f'Missing required statistics: {missing}\nAvailable: {list(stats.keys())}')
207
+
208
+ def _filter_data(self, data: pd.DataFrame, config: SignalConfig) -> pd.DataFrame:
209
+ """Apply filtering options."""
210
+ filtered = data.copy()
211
+
212
+ if config.ignore_first_n > 0:
213
+ filtered = filtered.iloc[config.ignore_first_n :]
214
+
215
+ if config.ignore_last_n > 0:
216
+ filtered = filtered.iloc[: -config.ignore_last_n]
217
+
218
+ return filtered
219
+
220
+ def _get_min_observations(self, rule_name: str) -> int:
221
+ """Get minimum observations for a rule."""
222
+ minimums = {
223
+ 'rule_1': 1,
224
+ 'rule_2': 3,
225
+ 'rule_3': 5,
226
+ 'rule_4': 8,
227
+ 'rule_5': 6,
228
+ 'rule_6': 14,
229
+ 'rule_7': 15,
230
+ 'rule_8': 8,
231
+ }
232
+ return minimums.get(rule_name, 1)
233
+
234
+ def _build_result(
235
+ self, data: pd.DataFrame, violations: pd.DataFrame, stats: dict, value_col: str, chart_name: str,
236
+ rules_skipped: dict[str, str] | None = None,
237
+ ) -> SignalResult:
238
+ """Build SignalResult from violation matrix."""
239
+ # Create violation records
240
+ records = []
241
+
242
+ # Check if limits vary
243
+ limits_vary = self._limits_vary(stats)
244
+
245
+ for idx in data.index:
246
+ for rule_name in violations.columns:
247
+ if violations.loc[idx, rule_name]:
248
+ # Use per-row limits if they vary, else use stats
249
+ if limits_vary and 'upl' in data.columns and 'lpl' in data.columns:
250
+ ucl = data.loc[idx, 'upl']
251
+ lcl = data.loc[idx, 'lpl']
252
+ else:
253
+ ucl = stats['upl']
254
+ lcl = stats['lpl']
255
+
256
+ records.append(
257
+ {
258
+ 'obs_id': idx,
259
+ 'rule_name': rule_name,
260
+ 'rule_number': int(rule_name.split('_')[1]),
261
+ 'description': self.RULE_DESCRIPTIONS[rule_name],
262
+ 'value': data.loc[idx, value_col],
263
+ 'center': stats['center'],
264
+ 'upl': ucl,
265
+ 'lpl': lcl,
266
+ }
267
+ )
268
+
269
+ violation_df = pd.DataFrame(records) if records else pd.DataFrame()
270
+
271
+ return SignalResult(violations=violation_df, chart_name=chart_name, data=data, stats=stats,
272
+ rules_skipped=rules_skipped)
273
+
274
+ def _limits_vary(self, stats: dict) -> bool:
275
+ """Check if control limits vary (per-row limits)."""
276
+ return bool(stats.get('limits_vary'))
277
+
278
+ def _calculate_per_row_zones(self, data: pd.DataFrame, stats: dict, config: SignalConfig) -> pd.DataFrame:
279
+ """
280
+ Calculate per-row zone boundaries for varying process limits.
281
+
282
+ When process limits vary per row (e.g., Xbar with varying n),
283
+ this calculates zone boundaries for each row based on that row's
284
+ specific upl/lpl values.
285
+
286
+ Parameters
287
+ ----------
288
+ data : DataFrame
289
+ Chart data with 'upl' and 'lpl' columns
290
+ stats : dict
291
+ Chart statistics with 'center' key
292
+ config : SignalConfig
293
+ Configuration with zone_definition
294
+
295
+ Returns
296
+ -------
297
+ DataFrame
298
+ Per-row zone boundaries with columns like 'A_upper_lower', 'A_upper_upper', etc.
299
+ """
300
+ if 'upl' not in data.columns or 'lpl' not in data.columns:
301
+ raise ValueError("Cannot calculate per-row zones: data missing 'upl' or 'lpl' columns")
302
+
303
+ center = stats['center']
304
+ zone_def = config.zone_definition
305
+
306
+ # Calculate sigma per row
307
+ sigma = (data['upl'] - center) / 3
308
+
309
+ # Calculate all zone boundaries per row
310
+ zones_df = pd.DataFrame(index=data.index)
311
+
312
+ # Upper zones
313
+ zones_df['A_upper_lower'] = center + zone_def.A[0] * sigma
314
+ zones_df['A_upper_upper'] = center + zone_def.A[1] * sigma
315
+ zones_df['B_upper_lower'] = center + zone_def.B[0] * sigma
316
+ zones_df['B_upper_upper'] = center + zone_def.B[1] * sigma
317
+ zones_df['C_upper_lower'] = center + zone_def.C[0] * sigma
318
+ zones_df['C_upper_upper'] = center + zone_def.C[1] * sigma
319
+
320
+ # Lower zones
321
+ zones_df['A_lower_lower'] = center - zone_def.A[1] * sigma
322
+ zones_df['A_lower_upper'] = center - zone_def.A[0] * sigma
323
+ zones_df['B_lower_lower'] = center - zone_def.B[1] * sigma
324
+ zones_df['B_lower_upper'] = center - zone_def.B[0] * sigma
325
+ zones_df['C_lower_lower'] = center - zone_def.C[1] * sigma
326
+ zones_df['C_lower_upper'] = center - zone_def.C[0] * sigma
327
+
328
+ return zones_df
@@ -0,0 +1,368 @@
1
+ """
2
+ Vectorized rule detection functions.
3
+
4
+ All detectors use pandas/numpy for performance.
5
+ Each detector returns a boolean Series indicating violations.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import pandas as pd
11
+
12
+
13
+ def detect_beyond_limits(data: pd.DataFrame, stats: dict, value_col: str, limits_vary: bool = False) -> pd.Series:
14
+ """
15
+ Rule 1: Points beyond control limits.
16
+
17
+ Detects any point beyond ±3σ from centerline.
18
+
19
+ Parameters
20
+ ----------
21
+ data : DataFrame
22
+ Chart data with optional 'upl' and 'lpl' columns for varying limits
23
+ stats : dict
24
+ Chart statistics with 'upl' and 'lpl' keys
25
+ value_col : str
26
+ Name of value column
27
+ limits_vary : bool, default False
28
+ If True, use per-row 'upl' and 'lpl' from data columns
29
+
30
+ Returns
31
+ -------
32
+ Series of bool
33
+ True for violations
34
+ """
35
+ values = data[value_col]
36
+
37
+ # Use per-row limits if they vary, else use constant limits from stats
38
+ if limits_vary and 'upl' in data.columns and 'lpl' in data.columns:
39
+ ucl = data['upl']
40
+ lcl = data['lpl']
41
+ else:
42
+ ucl = stats['upl']
43
+ lcl = stats['lpl']
44
+
45
+ return (values > ucl) | (values < lcl)
46
+
47
+
48
+ def detect_zone_a_2_of_3(
49
+ data: pd.DataFrame,
50
+ stats: dict,
51
+ value_col: str,
52
+ zones, # dict or DataFrame
53
+ limits_vary: bool = False,
54
+ ) -> pd.Series:
55
+ """
56
+ Rule 2: 2 of 3 consecutive points in Zone A.
57
+
58
+ Detects when 2 out of 3 consecutive points fall in Zone A
59
+ (between 2σ and 3σ from centerline), same side.
60
+
61
+ Parameters
62
+ ----------
63
+ data : DataFrame
64
+ Chart data
65
+ stats : dict
66
+ Chart statistics
67
+ value_col : str
68
+ Name of value column
69
+ zones : dict or DataFrame
70
+ Zone boundaries (dict if constant, DataFrame if per-row)
71
+ limits_vary : bool, default False
72
+ If True, zones is a DataFrame with per-row boundaries
73
+
74
+ Returns
75
+ -------
76
+ Series of bool
77
+ True for violations
78
+ """
79
+ values = data[value_col]
80
+
81
+ # Determine which zone each point is in
82
+ if limits_vary:
83
+ # Per-row zones
84
+ in_upper_a = (values > zones['A_upper_lower']) & (values <= zones['A_upper_upper'])
85
+ in_lower_a = (values >= zones['A_lower_lower']) & (values < zones['A_lower_upper'])
86
+ else:
87
+ # Constant zones
88
+ in_upper_a = (values > zones['A_upper'][0]) & (values <= zones['A_upper'][1])
89
+ in_lower_a = (values >= zones['A_lower'][0]) & (values < zones['A_lower'][1])
90
+
91
+ # Rolling window count
92
+ upper_count = in_upper_a.astype(int).rolling(window=3, min_periods=3).sum()
93
+ lower_count = in_lower_a.astype(int).rolling(window=3, min_periods=3).sum()
94
+
95
+ # Flag if 2 or more in window
96
+ violations = (upper_count >= 2) | (lower_count >= 2)
97
+
98
+ return violations.fillna(False)
99
+
100
+
101
+ def detect_zone_b_4_of_5(
102
+ data: pd.DataFrame,
103
+ stats: dict,
104
+ value_col: str,
105
+ zones, # dict or DataFrame
106
+ limits_vary: bool = False,
107
+ ) -> pd.Series:
108
+ """
109
+ Rule 3: 4 of 5 consecutive points in Zone B or beyond.
110
+
111
+ Detects when 4 out of 5 consecutive points fall in Zone B or beyond
112
+ (beyond 1σ from centerline), same side.
113
+
114
+ Parameters
115
+ ----------
116
+ data : DataFrame
117
+ Chart data
118
+ stats : dict
119
+ Chart statistics
120
+ value_col : str
121
+ Name of value column
122
+ zones : dict or DataFrame
123
+ Zone boundaries (dict if constant, DataFrame if per-row)
124
+ limits_vary : bool, default False
125
+ If True, zones is a DataFrame with per-row boundaries
126
+
127
+ Returns
128
+ -------
129
+ Series of bool
130
+ True for violations
131
+ """
132
+ values = data[value_col]
133
+
134
+ # In Zone B or beyond (same side)
135
+ if limits_vary:
136
+ # Per-row zones
137
+ upper = values > zones['B_upper_lower']
138
+ lower = values < zones['B_lower_upper']
139
+ else:
140
+ # Constant zones
141
+ upper = values > zones['B_upper'][0]
142
+ lower = values < zones['B_lower'][1]
143
+
144
+ upper_count = upper.astype(int).rolling(window=5, min_periods=5).sum()
145
+ lower_count = lower.astype(int).rolling(window=5, min_periods=5).sum()
146
+
147
+ violations = (upper_count >= 4) | (lower_count >= 4)
148
+ return violations.fillna(False)
149
+
150
+
151
+ def detect_run(data: pd.DataFrame, stats: dict, value_col: str, length: int = 8) -> pd.Series:
152
+ """
153
+ Rule 4: Run of points on same side of centerline.
154
+
155
+ Detects when 8 or more consecutive points fall on the same side
156
+ of the centerline.
157
+
158
+ Parameters
159
+ ----------
160
+ data : DataFrame
161
+ Chart data
162
+ stats : dict
163
+ Chart statistics with 'center' key
164
+ value_col : str
165
+ Name of value column
166
+ length : int, default 8
167
+ Number of consecutive points required
168
+
169
+ Returns
170
+ -------
171
+ Series of bool
172
+ True for violations
173
+ """
174
+ values = data[value_col]
175
+ center = stats.get('center', stats.get('Mean')) # Try 'center' first, fallback to 'Mean'
176
+
177
+ # Above or below center
178
+ above = (values > center).astype(int)
179
+ below = (values < center).astype(int)
180
+
181
+ # Count consecutive
182
+ above_streak = above.rolling(window=length, min_periods=length).sum()
183
+ below_streak = below.rolling(window=length, min_periods=length).sum()
184
+
185
+ violations = (above_streak == length) | (below_streak == length)
186
+ return violations.fillna(False)
187
+
188
+
189
+ def detect_trend(
190
+ data: pd.DataFrame, stats: dict, value_col: str, length: int = 6, direction: str = 'both'
191
+ ) -> pd.Series:
192
+ """
193
+ Rule 5: Trending sequence.
194
+
195
+ Detects when 6 or more consecutive points are steadily
196
+ increasing or decreasing.
197
+
198
+ Parameters
199
+ ----------
200
+ data : DataFrame
201
+ Chart data
202
+ stats : dict
203
+ Chart statistics (not used but kept for consistency)
204
+ value_col : str
205
+ Name of value column
206
+ length : int, default 6
207
+ Number of consecutive points required
208
+ direction : {'up', 'down', 'both'}, default 'both'
209
+ Which direction trends to detect
210
+
211
+ Returns
212
+ -------
213
+ Series of bool
214
+ True for violations
215
+ """
216
+ values = data[value_col]
217
+
218
+ # Calculate differences
219
+ diffs = values.diff()
220
+
221
+ up_trend = pd.Series(False, index=values.index)
222
+ down_trend = pd.Series(False, index=values.index)
223
+
224
+ if direction in ['up', 'both']:
225
+ increasing = (diffs > 0).astype(int).rolling(window=length, min_periods=length).sum()
226
+ up_trend = increasing == length
227
+
228
+ if direction in ['down', 'both']:
229
+ decreasing = (diffs < 0).astype(int).rolling(window=length, min_periods=length).sum()
230
+ down_trend = decreasing == length
231
+
232
+ violations = up_trend | down_trend
233
+ return violations.fillna(False)
234
+
235
+
236
+ def detect_oscillation(data: pd.DataFrame, stats: dict, value_col: str, length: int = 14) -> pd.Series:
237
+ """
238
+ Rule 6: Alternating up-down pattern.
239
+
240
+ Detects when 14 or more consecutive points alternate up and down.
241
+
242
+ Parameters
243
+ ----------
244
+ data : DataFrame
245
+ Chart data
246
+ stats : dict
247
+ Chart statistics (not used but kept for consistency)
248
+ value_col : str
249
+ Name of value column
250
+ length : int, default 14
251
+ Number of consecutive points required
252
+
253
+ Returns
254
+ -------
255
+ Series of bool
256
+ True for violations
257
+ """
258
+ values = data[value_col]
259
+
260
+ # Detect direction changes
261
+ diffs = values.diff()
262
+ changes = (diffs * diffs.shift(1) < 0).astype(int)
263
+
264
+ # Count consecutive changes
265
+ change_count = changes.rolling(window=length - 1, min_periods=length - 1).sum()
266
+
267
+ violations = change_count == (length - 1)
268
+ return violations.fillna(False)
269
+
270
+
271
+ def detect_reduced_variation(
272
+ data: pd.DataFrame,
273
+ stats: dict,
274
+ value_col: str,
275
+ zones, # dict or DataFrame
276
+ limits_vary: bool = False,
277
+ length: int = 15,
278
+ ) -> pd.Series:
279
+ """
280
+ Rule 7: Points within Zone C (reduced variation).
281
+
282
+ Detects when 15 or more consecutive points fall within Zone C
283
+ (within ±1σ of centerline), indicating reduced variation.
284
+
285
+ Parameters
286
+ ----------
287
+ data : DataFrame
288
+ Chart data
289
+ stats : dict
290
+ Chart statistics
291
+ value_col : str
292
+ Name of value column
293
+ zones : dict or DataFrame
294
+ Zone boundaries (dict if constant, DataFrame if per-row)
295
+ limits_vary : bool, default False
296
+ If True, zones is a DataFrame with per-row boundaries
297
+ length : int, default 15
298
+ Number of consecutive points required
299
+
300
+ Returns
301
+ -------
302
+ Series of bool
303
+ True for violations
304
+ """
305
+ values = data[value_col]
306
+
307
+ # Within Zone C
308
+ if limits_vary:
309
+ # Per-row zones
310
+ in_zone_c = (values > zones['C_lower_lower']) & (values < zones['C_upper_upper'])
311
+ else:
312
+ # Constant zones
313
+ in_zone_c = (values > zones['C_lower'][0]) & (values < zones['C_upper'][1])
314
+
315
+ in_c_count = in_zone_c.astype(int).rolling(window=length, min_periods=length).sum()
316
+
317
+ violations = in_c_count == length
318
+ return violations.fillna(False)
319
+
320
+
321
+ def detect_avoiding_center(
322
+ data: pd.DataFrame,
323
+ stats: dict,
324
+ value_col: str,
325
+ zones, # dict or DataFrame
326
+ limits_vary: bool = False,
327
+ length: int = 8,
328
+ ) -> pd.Series:
329
+ """
330
+ Rule 8: Points avoiding Zone C.
331
+
332
+ Detects when 8 or more consecutive points fall outside Zone C
333
+ (beyond ±1σ from centerline).
334
+
335
+ Parameters
336
+ ----------
337
+ data : DataFrame
338
+ Chart data
339
+ stats : dict
340
+ Chart statistics
341
+ value_col : str
342
+ Name of value column
343
+ zones : dict or DataFrame
344
+ Zone boundaries (dict if constant, DataFrame if per-row)
345
+ limits_vary : bool, default False
346
+ If True, zones is a DataFrame with per-row boundaries
347
+ length : int, default 8
348
+ Number of consecutive points required
349
+
350
+ Returns
351
+ -------
352
+ Series of bool
353
+ True for violations
354
+ """
355
+ values = data[value_col]
356
+
357
+ # Outside Zone C
358
+ if limits_vary:
359
+ # Per-row zones
360
+ outside_c = (values <= zones['C_lower_lower']) | (values >= zones['C_upper_upper'])
361
+ else:
362
+ # Constant zones
363
+ outside_c = (values <= zones['C_lower'][0]) | (values >= zones['C_upper'][1])
364
+
365
+ outside_count = outside_c.astype(int).rolling(window=length, min_periods=length).sum()
366
+
367
+ violations = outside_count == length
368
+ return violations.fillna(False)