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,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)
|