detectkit 0.2.4__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 (51) hide show
  1. detectkit/__init__.py +17 -0
  2. detectkit/alerting/__init__.py +13 -0
  3. detectkit/alerting/channels/__init__.py +21 -0
  4. detectkit/alerting/channels/base.py +193 -0
  5. detectkit/alerting/channels/email.py +146 -0
  6. detectkit/alerting/channels/factory.py +193 -0
  7. detectkit/alerting/channels/mattermost.py +53 -0
  8. detectkit/alerting/channels/slack.py +55 -0
  9. detectkit/alerting/channels/telegram.py +110 -0
  10. detectkit/alerting/channels/webhook.py +139 -0
  11. detectkit/alerting/orchestrator.py +369 -0
  12. detectkit/cli/__init__.py +1 -0
  13. detectkit/cli/commands/__init__.py +1 -0
  14. detectkit/cli/commands/init.py +282 -0
  15. detectkit/cli/commands/run.py +486 -0
  16. detectkit/cli/commands/test_alert.py +184 -0
  17. detectkit/cli/main.py +186 -0
  18. detectkit/config/__init__.py +30 -0
  19. detectkit/config/metric_config.py +499 -0
  20. detectkit/config/profile.py +285 -0
  21. detectkit/config/project_config.py +164 -0
  22. detectkit/config/validator.py +124 -0
  23. detectkit/core/__init__.py +6 -0
  24. detectkit/core/interval.py +132 -0
  25. detectkit/core/models.py +106 -0
  26. detectkit/database/__init__.py +27 -0
  27. detectkit/database/clickhouse_manager.py +393 -0
  28. detectkit/database/internal_tables.py +724 -0
  29. detectkit/database/manager.py +324 -0
  30. detectkit/database/tables.py +138 -0
  31. detectkit/detectors/__init__.py +6 -0
  32. detectkit/detectors/base.py +441 -0
  33. detectkit/detectors/factory.py +138 -0
  34. detectkit/detectors/statistical/__init__.py +8 -0
  35. detectkit/detectors/statistical/iqr.py +508 -0
  36. detectkit/detectors/statistical/mad.py +478 -0
  37. detectkit/detectors/statistical/manual_bounds.py +206 -0
  38. detectkit/detectors/statistical/zscore.py +491 -0
  39. detectkit/loaders/__init__.py +6 -0
  40. detectkit/loaders/metric_loader.py +470 -0
  41. detectkit/loaders/query_template.py +164 -0
  42. detectkit/orchestration/__init__.py +9 -0
  43. detectkit/orchestration/task_manager.py +746 -0
  44. detectkit/utils/__init__.py +17 -0
  45. detectkit/utils/stats.py +196 -0
  46. detectkit-0.2.4.dist-info/METADATA +237 -0
  47. detectkit-0.2.4.dist-info/RECORD +51 -0
  48. detectkit-0.2.4.dist-info/WHEEL +5 -0
  49. detectkit-0.2.4.dist-info/entry_points.txt +2 -0
  50. detectkit-0.2.4.dist-info/licenses/LICENSE +21 -0
  51. detectkit-0.2.4.dist-info/top_level.txt +1 -0
@@ -0,0 +1,478 @@
1
+ """
2
+ Median Absolute Deviation (MAD) anomaly detector.
3
+
4
+ MAD is a robust statistical method for outlier detection that:
5
+ - Uses median (robust to outliers) instead of mean
6
+ - Measures deviation from median using MAD instead of std
7
+ - Less sensitive to extreme values than Z-Score
8
+
9
+ Formula:
10
+ - median_val = median(values)
11
+ - mad_val = median(|values - median_val|)
12
+ - lower_bound = median_val - threshold × mad_val
13
+ - upper_bound = median_val + threshold × mad_val
14
+
15
+ Seasonality support:
16
+ - Groups data by seasonality components
17
+ - Computes global statistics (entire window)
18
+ - Computes component statistics (per group)
19
+ - Applies multipliers to adjust confidence intervals
20
+ """
21
+
22
+ from typing import Any, Dict, List, Optional, Tuple, Union
23
+ import json
24
+
25
+ import numpy as np
26
+
27
+ from detectkit.detectors.base import BaseDetector, DetectionResult
28
+
29
+
30
+ class MADDetector(BaseDetector):
31
+ """
32
+ Median Absolute Deviation detector for anomaly detection.
33
+
34
+ Detects anomalies by comparing values against confidence intervals
35
+ based on median and MAD (median absolute deviation).
36
+
37
+ Parameters:
38
+ threshold (float): Number of MAD units from median (default: 3.0)
39
+ - 3.0 is standard (similar to 3-sigma in Z-Score)
40
+ - Higher = less sensitive (fewer anomalies)
41
+ - Lower = more sensitive (more anomalies)
42
+
43
+ window_size (int): Historical window size in points (default: 100)
44
+ - Uses last N points to compute statistics
45
+ - Larger = more stable but less responsive
46
+ - Smaller = more responsive but less stable
47
+
48
+ min_samples (int): Minimum samples required for detection (default: 30)
49
+ - Skip detection if window has fewer valid points
50
+ - Ensures statistical reliability
51
+
52
+ Example:
53
+ >>> detector = MADDetector(threshold=3.0, window_size=100)
54
+ >>> results = detector.detect(data)
55
+ >>> for r in results:
56
+ ... if r.is_anomaly:
57
+ ... print(f"Anomaly: {r.value} outside [{r.confidence_lower}, {r.confidence_upper}]")
58
+ """
59
+
60
+ def __init__(
61
+ self,
62
+ threshold: float = 3.0,
63
+ window_size: int = 100,
64
+ min_samples: int = 30,
65
+ seasonality_components: Optional[List[Union[str, List[str]]]] = None,
66
+ min_samples_per_group: int = 10,
67
+ input_type: str = "values",
68
+ smoothing: Optional[str] = None,
69
+ smoothing_alpha: float = 0.3,
70
+ smoothing_window: int = 10,
71
+ window_weights: Optional[str] = None,
72
+ weight_decay: float = 0.95,
73
+ ):
74
+ """
75
+ Initialize MAD detector with parameters.
76
+
77
+ Args:
78
+ threshold: Number of MAD units from median
79
+ window_size: Historical window size in points
80
+ min_samples: Minimum total samples required
81
+ seasonality_components: Optional list of seasonality groups
82
+ Examples:
83
+ - ["day_of_week"] - single component
84
+ - [["day_of_week", "hour"]] - combined group
85
+ - ["day", ["hour", "minute"]] - separate + combined
86
+ min_samples_per_group: Minimum samples per seasonality group
87
+ input_type: Input transformation type (values, changes, absolute_changes, log_changes)
88
+ smoothing: Smoothing method (None, ema, sma)
89
+ smoothing_alpha: EMA smoothing factor (0 < alpha <= 1)
90
+ smoothing_window: SMA window size
91
+ window_weights: Weighting method (None, exponential, linear)
92
+ weight_decay: Decay factor for exponential weights (0 < decay < 1)
93
+ """
94
+ super().__init__(
95
+ threshold=threshold,
96
+ window_size=window_size,
97
+ min_samples=min_samples,
98
+ seasonality_components=seasonality_components,
99
+ min_samples_per_group=min_samples_per_group,
100
+ input_type=input_type,
101
+ smoothing=smoothing,
102
+ smoothing_alpha=smoothing_alpha,
103
+ smoothing_window=smoothing_window,
104
+ window_weights=window_weights,
105
+ weight_decay=weight_decay,
106
+ )
107
+
108
+ def _validate_params(self):
109
+ """Validate detector parameters."""
110
+ threshold = self.params.get("threshold")
111
+ if threshold is None or threshold <= 0:
112
+ raise ValueError("threshold must be positive")
113
+
114
+ window_size = self.params.get("window_size")
115
+ if window_size is None or window_size < 1:
116
+ raise ValueError("window_size must be at least 1")
117
+
118
+ min_samples = self.params.get("min_samples")
119
+ if min_samples is None or min_samples < 1:
120
+ raise ValueError("min_samples must be at least 1")
121
+
122
+ if min_samples > window_size:
123
+ raise ValueError("min_samples cannot exceed window_size")
124
+
125
+ def _parse_seasonality_data(
126
+ self, seasonality_data: np.ndarray, seasonality_columns: List[str]
127
+ ) -> Dict[str, np.ndarray]:
128
+ """
129
+ Parse seasonality JSON strings into structured data.
130
+
131
+ Args:
132
+ seasonality_data: Array of JSON strings
133
+ seasonality_columns: List of column names
134
+
135
+ Returns:
136
+ Dict with column names as keys, numpy arrays as values
137
+
138
+ Example:
139
+ Input: ['{"day": 1, "hour": 10}', '{"day": 1, "hour": 11}']
140
+ Output: {"day": array([1, 1]), "hour": array([10, 11])}
141
+ """
142
+ if len(seasonality_data) == 0:
143
+ return {}
144
+
145
+ # Parse all JSON strings
146
+ parsed_data = {col: [] for col in seasonality_columns}
147
+
148
+ for json_str in seasonality_data:
149
+ if json_str is None or json_str == "{}":
150
+ # Empty seasonality - add None for all columns
151
+ for col in seasonality_columns:
152
+ parsed_data[col].append(None)
153
+ else:
154
+ try:
155
+ data_dict = json.loads(json_str)
156
+ for col in seasonality_columns:
157
+ parsed_data[col].append(data_dict.get(col))
158
+ except (json.JSONDecodeError, TypeError):
159
+ # Invalid JSON - add None
160
+ for col in seasonality_columns:
161
+ parsed_data[col].append(None)
162
+
163
+ # Convert to numpy arrays
164
+ return {col: np.array(vals) for col, vals in parsed_data.items()}
165
+
166
+ def _create_seasonality_mask(
167
+ self,
168
+ seasonality_dict: Dict[str, np.ndarray],
169
+ window_start: int,
170
+ current_idx: int,
171
+ group_columns: List[str],
172
+ ) -> np.ndarray:
173
+ """
174
+ Create boolean mask for seasonality group.
175
+
176
+ Args:
177
+ seasonality_dict: Parsed seasonality data
178
+ window_start: Start index of window
179
+ current_idx: Current point index
180
+ group_columns: List of columns to group by (e.g., ["day", "hour"])
181
+
182
+ Returns:
183
+ Boolean mask for window indices matching current point's seasonality
184
+
185
+ Example:
186
+ Current point: day=1, hour=10
187
+ Group columns: ["day", "hour"]
188
+ Returns: mask where (day==1) AND (hour==10)
189
+ """
190
+ if not group_columns or not seasonality_dict:
191
+ # No grouping - return all True
192
+ window_size = current_idx - window_start
193
+ return np.ones(window_size, dtype=bool)
194
+
195
+ # Get current point's seasonality values
196
+ current_values = {}
197
+ for col in group_columns:
198
+ if col in seasonality_dict:
199
+ current_values[col] = seasonality_dict[col][current_idx]
200
+ else:
201
+ # Column not found - no filtering
202
+ return np.ones(current_idx - window_start, dtype=bool)
203
+
204
+ # Create combined mask (AND of all columns)
205
+ mask = np.ones(current_idx - window_start, dtype=bool)
206
+
207
+ for col in group_columns:
208
+ current_val = current_values[col]
209
+ window_vals = seasonality_dict[col][window_start:current_idx]
210
+ mask &= (window_vals == current_val)
211
+
212
+ return mask
213
+
214
+ def detect(self, data: Dict[str, np.ndarray]) -> list[DetectionResult]:
215
+ """
216
+ Perform MAD-based anomaly detection with seasonality support.
217
+
218
+ Algorithm (TECHNICAL_SPEC.md section 8):
219
+ 1. Apply preprocessing (input_type transformation and smoothing)
220
+ 2. Parse seasonality data
221
+ 3. For each point:
222
+ - Compute global statistics (entire window)
223
+ - Apply weighting if specified
224
+ - For each seasonality group:
225
+ * Create mask matching current point's seasonality
226
+ * Compute group statistics
227
+ * Calculate multipliers
228
+ - Apply all multipliers to adjust intervals
229
+ - Detect anomalies
230
+
231
+ Args:
232
+ data: Dictionary with keys:
233
+ - timestamp: np.array of datetime64[ms]
234
+ - value: np.array of float64 (may contain NaN)
235
+ - seasonality_data: np.array of JSON strings
236
+ - seasonality_columns: list of column names
237
+
238
+ Returns:
239
+ List of DetectionResult for each point
240
+ """
241
+ timestamps = data["timestamp"]
242
+ values = data["value"] # ORIGINAL values (always kept)
243
+ seasonality_data = data.get("seasonality_data", np.array([]))
244
+ seasonality_columns = data.get("seasonality_columns", [])
245
+
246
+ threshold = self.params["threshold"]
247
+ window_size = self.params["window_size"]
248
+ min_samples = self.params["min_samples"]
249
+ seasonality_components = self.params.get("seasonality_components")
250
+ min_samples_per_group = self.params.get("min_samples_per_group", 10)
251
+
252
+ # STEP 0: Preprocessing (smoothing + input_type transformation)
253
+ # Order matters: smoothing first, then input_type
254
+ smoothed_values = self._apply_smoothing(values)
255
+ processed_values = self._preprocess_input(smoothed_values)
256
+
257
+ # Parse seasonality data once
258
+ seasonality_dict = {}
259
+ if len(seasonality_data) > 0 and seasonality_columns:
260
+ seasonality_dict = self._parse_seasonality_data(
261
+ seasonality_data, seasonality_columns
262
+ )
263
+
264
+ results = []
265
+ n_points = len(timestamps)
266
+
267
+ for i in range(n_points):
268
+ current_val = values[i] # ORIGINAL value
269
+ current_processed = processed_values[i] # PROCESSED value
270
+ current_ts = timestamps[i]
271
+
272
+ # Skip NaN values (in processed)
273
+ if np.isnan(current_processed):
274
+ results.append(
275
+ DetectionResult(
276
+ timestamp=current_ts,
277
+ value=current_val,
278
+ processed_value=current_processed,
279
+ is_anomaly=False,
280
+ detection_metadata={"reason": "missing_data"},
281
+ )
282
+ )
283
+ continue
284
+
285
+ # Get historical window (not including current point)
286
+ window_start = max(0, i - window_size)
287
+ window_processed = processed_values[window_start:i]
288
+
289
+ # Filter out NaN values from window
290
+ valid_mask = ~np.isnan(window_processed)
291
+ window_valid = window_processed[valid_mask]
292
+
293
+ # Check if we have enough samples
294
+ if len(window_valid) < min_samples:
295
+ results.append(
296
+ DetectionResult(
297
+ timestamp=current_ts,
298
+ value=current_val,
299
+ processed_value=current_processed,
300
+ is_anomaly=False,
301
+ detection_metadata={
302
+ "reason": "insufficient_data",
303
+ "window_size": int(len(window_valid)),
304
+ "min_samples": min_samples,
305
+ },
306
+ )
307
+ )
308
+ continue
309
+
310
+ # Compute weights for window (if specified)
311
+ weights = self._compute_weights(len(window_valid))
312
+
313
+ # STEP 1: Compute GLOBAL statistics (entire window)
314
+ # Use weighted statistics if weights are not uniform
315
+ from detectkit.utils import weighted_median, weighted_mad
316
+
317
+ global_median = weighted_median(window_valid, weights)
318
+ global_mad = weighted_mad(window_valid, weights, center=global_median)
319
+
320
+ # Initialize adjusted statistics with global values
321
+ adjusted_median = global_median
322
+ adjusted_mad = global_mad
323
+
324
+ # STEP 2: Apply seasonality adjustments
325
+ multipliers_applied = []
326
+
327
+ if seasonality_components and seasonality_dict:
328
+ # Process each seasonality group
329
+ for group in seasonality_components:
330
+ # Normalize to list (handle both str and List[str])
331
+ group_cols = [group] if isinstance(group, str) else group
332
+
333
+ # Create mask for this group
334
+ season_mask = self._create_seasonality_mask(
335
+ seasonality_dict, window_start, i, group_cols
336
+ )
337
+
338
+ # Apply mask to window (only valid values + seasonality match)
339
+ # Both valid_mask and season_mask are same size as window_processed
340
+ combined_mask = valid_mask & season_mask
341
+
342
+ group_values = window_processed[combined_mask]
343
+
344
+ # Check if enough samples in group
345
+ if len(group_values) < min_samples_per_group:
346
+ # Insufficient data - skip this group (multiplier = 1.0)
347
+ multipliers_applied.append({
348
+ "group": group_cols,
349
+ "median_multiplier": 1.0,
350
+ "mad_multiplier": 1.0,
351
+ "reason": "insufficient_group_data",
352
+ "group_size": int(len(group_values)),
353
+ })
354
+ continue
355
+
356
+ # Compute group statistics with weights
357
+ group_weights = self._compute_weights(len(group_values))
358
+ group_median = weighted_median(group_values, group_weights)
359
+ group_mad = weighted_mad(group_values, group_weights, center=group_median)
360
+
361
+ # Calculate multipliers
362
+ if global_median != 0:
363
+ median_multiplier = group_median / global_median
364
+ else:
365
+ median_multiplier = 1.0
366
+
367
+ if global_mad != 0:
368
+ mad_multiplier = group_mad / global_mad
369
+ else:
370
+ mad_multiplier = 1.0
371
+
372
+ # Apply multipliers
373
+ adjusted_median *= median_multiplier
374
+ adjusted_mad *= mad_multiplier
375
+
376
+ multipliers_applied.append({
377
+ "group": group_cols,
378
+ "median_multiplier": float(median_multiplier),
379
+ "mad_multiplier": float(mad_multiplier),
380
+ "group_size": int(len(group_values)),
381
+ })
382
+
383
+ # STEP 3: Build confidence interval
384
+ if adjusted_mad == 0:
385
+ # All values identical - any deviation is anomalous
386
+ confidence_lower = adjusted_median - 1e-10
387
+ confidence_upper = adjusted_median + 1e-10
388
+ else:
389
+ confidence_lower = adjusted_median - threshold * adjusted_mad
390
+ confidence_upper = adjusted_median + threshold * adjusted_mad
391
+
392
+ # STEP 4: Check if current PROCESSED value is anomalous
393
+ is_anomaly = (current_processed < confidence_lower) or (current_processed > confidence_upper)
394
+
395
+ # Build metadata
396
+ metadata = {
397
+ "global_median": float(global_median),
398
+ "global_mad": float(global_mad),
399
+ "adjusted_median": float(adjusted_median),
400
+ "adjusted_mad": float(adjusted_mad),
401
+ "window_size": int(len(window_valid)),
402
+ }
403
+
404
+ # Add preprocessing info if used
405
+ if self.params.get("smoothing") or self.params.get("input_type") != "values":
406
+ metadata["preprocessing"] = {
407
+ "input_type": self.params.get("input_type", "values"),
408
+ "smoothing": self.params.get("smoothing"),
409
+ }
410
+ if self.params.get("smoothing"):
411
+ metadata["preprocessing"]["smoothed_value"] = float(smoothed_values[i])
412
+
413
+ if seasonality_components and multipliers_applied:
414
+ metadata["seasonality_groups"] = multipliers_applied
415
+
416
+ if is_anomaly:
417
+ if current_processed < confidence_lower:
418
+ direction = "below"
419
+ distance = confidence_lower - current_processed
420
+ else:
421
+ direction = "above"
422
+ distance = current_processed - confidence_upper
423
+
424
+ # Severity: how many adjusted MAD units away
425
+ severity = distance / adjusted_mad if adjusted_mad > 0 else float("inf")
426
+
427
+ metadata.update({
428
+ "direction": direction,
429
+ "severity": float(severity),
430
+ "distance": float(distance),
431
+ })
432
+
433
+ results.append(
434
+ DetectionResult(
435
+ timestamp=current_ts,
436
+ value=current_val, # ORIGINAL value
437
+ processed_value=current_processed, # PROCESSED value
438
+ is_anomaly=is_anomaly,
439
+ confidence_lower=float(confidence_lower),
440
+ confidence_upper=float(confidence_upper),
441
+ detection_metadata=metadata,
442
+ )
443
+ )
444
+
445
+ return results
446
+
447
+ def _get_non_default_params(self) -> Dict[str, Any]:
448
+ """
449
+ Get parameters that differ from defaults.
450
+
451
+ Excludes execution parameters (seasonality_components, min_samples_per_group)
452
+ from detector ID hash.
453
+ """
454
+ defaults = {
455
+ "threshold": 3.0,
456
+ "window_size": 100,
457
+ "min_samples": 30,
458
+ "min_samples_per_group": 10,
459
+ "input_type": "values",
460
+ "smoothing": None,
461
+ "smoothing_alpha": 0.3,
462
+ "smoothing_window": 10,
463
+ "window_weights": None,
464
+ "weight_decay": 0.95,
465
+ }
466
+ # Execution parameters that don't affect detector ID
467
+ execution_params = {
468
+ "seasonality_components",
469
+ "min_samples_per_group",
470
+ "smoothing_alpha", # Only affects smoothing, not algorithm
471
+ "smoothing_window", # Only affects smoothing, not algorithm
472
+ "weight_decay", # Only affects weighting, not algorithm
473
+ }
474
+
475
+ return {
476
+ k: v for k, v in self.params.items()
477
+ if v != defaults.get(k) and k not in execution_params
478
+ }
@@ -0,0 +1,206 @@
1
+ """
2
+ Manual Bounds anomaly detector.
3
+
4
+ Simple detector that uses user-specified thresholds for anomaly detection.
5
+ Useful when domain knowledge exists about acceptable ranges.
6
+
7
+ Examples:
8
+ - CPU usage should be <= 90%
9
+ - Response time should be <= 1000ms
10
+ - Queue size should be >= 0 and <= 10000
11
+ """
12
+
13
+ from typing import Any, Dict, Optional
14
+
15
+ import numpy as np
16
+
17
+ from detectkit.detectors.base import BaseDetector, DetectionResult
18
+
19
+
20
+ class ManualBoundsDetector(BaseDetector):
21
+ """
22
+ Manual threshold detector for anomaly detection.
23
+
24
+ Detects anomalies by comparing values against user-specified bounds.
25
+ Does not use historical data - purely threshold-based.
26
+
27
+ Parameters:
28
+ lower_bound (float | None): Minimum acceptable value (default: None = no lower limit)
29
+ - Values below this are anomalous
30
+ - None means no lower bound
31
+
32
+ upper_bound (float | None): Maximum acceptable value (default: None = no upper limit)
33
+ - Values above this are anomalous
34
+ - None means no upper bound
35
+
36
+ At least one bound must be specified.
37
+
38
+ Example:
39
+ >>> # Detect values above 100
40
+ >>> detector = ManualBoundsDetector(upper_bound=100.0)
41
+ >>> results = detector.detect(data)
42
+
43
+ >>> # Detect values outside [10, 90]
44
+ >>> detector = ManualBoundsDetector(lower_bound=10.0, upper_bound=90.0)
45
+ >>> results = detector.detect(data)
46
+ """
47
+
48
+ def __init__(
49
+ self,
50
+ lower_bound: Optional[float] = None,
51
+ upper_bound: Optional[float] = None,
52
+ input_type: str = "values",
53
+ ):
54
+ """
55
+ Initialize Manual Bounds detector with thresholds.
56
+
57
+ Args:
58
+ lower_bound: Minimum acceptable value (None = no lower limit)
59
+ upper_bound: Maximum acceptable value (None = no upper limit)
60
+ input_type: Input transformation type (values, changes, absolute_changes, log_changes)
61
+ """
62
+ super().__init__(
63
+ lower_bound=lower_bound,
64
+ upper_bound=upper_bound,
65
+ input_type=input_type,
66
+ )
67
+
68
+ def _validate_params(self):
69
+ """Validate detector parameters."""
70
+ lower_bound = self.params.get("lower_bound")
71
+ upper_bound = self.params.get("upper_bound")
72
+
73
+ # At least one bound must be specified
74
+ if lower_bound is None and upper_bound is None:
75
+ raise ValueError("At least one of lower_bound or upper_bound must be specified")
76
+
77
+ # If both specified, lower must be less than upper
78
+ if lower_bound is not None and upper_bound is not None:
79
+ if lower_bound >= upper_bound:
80
+ raise ValueError("lower_bound must be less than upper_bound")
81
+
82
+ def detect(self, data: Dict[str, np.ndarray]) -> list[DetectionResult]:
83
+ """
84
+ Perform threshold-based anomaly detection.
85
+
86
+ Simply checks if each value is outside the specified bounds.
87
+ Does not use historical window - purely threshold-based.
88
+
89
+ Args:
90
+ data: Dictionary with keys:
91
+ - timestamp: np.array of datetime64[ms]
92
+ - value: np.array of float64 (may contain NaN)
93
+ - seasonality_data: np.array of JSON strings (not used)
94
+ - seasonality_columns: list of column names (not used)
95
+
96
+ Returns:
97
+ List of DetectionResult for each point
98
+
99
+ Notes:
100
+ - NaN values are skipped (marked as non-anomalous)
101
+ - No historical window needed
102
+ - No minimum samples requirement
103
+ """
104
+ timestamps = data["timestamp"]
105
+ values = data["value"] # ORIGINAL values (always kept)
106
+ lower_bound = self.params.get("lower_bound")
107
+ upper_bound = self.params.get("upper_bound")
108
+
109
+ # STEP 0: Preprocessing (input_type transformation only, no smoothing)
110
+ # Note: ManualBounds doesn't use smoothing or weights (no historical window)
111
+ processed_values = self._preprocess_input(values)
112
+
113
+ results = []
114
+ n_points = len(timestamps)
115
+
116
+ for i in range(n_points):
117
+ current_val = values[i] # ORIGINAL value
118
+ current_processed = processed_values[i] # PROCESSED value
119
+ current_ts = timestamps[i]
120
+
121
+ # Skip NaN values (in processed)
122
+ if np.isnan(current_processed):
123
+ results.append(
124
+ DetectionResult(
125
+ timestamp=current_ts,
126
+ value=current_val,
127
+ processed_value=current_processed,
128
+ is_anomaly=False,
129
+ detection_metadata={"reason": "missing_data"},
130
+ )
131
+ )
132
+ continue
133
+
134
+ # Check bounds on PROCESSED value
135
+ is_anomaly = False
136
+ direction = None
137
+ distance = 0.0
138
+
139
+ if lower_bound is not None and current_processed < lower_bound:
140
+ is_anomaly = True
141
+ direction = "below"
142
+ distance = lower_bound - current_processed
143
+
144
+ if upper_bound is not None and current_processed > upper_bound:
145
+ is_anomaly = True
146
+ direction = "above"
147
+ distance = current_processed - upper_bound
148
+
149
+ # Prepare metadata
150
+ metadata = {}
151
+
152
+ # Add preprocessing info if used
153
+ if self.params.get("input_type") != "values":
154
+ metadata["preprocessing"] = {
155
+ "input_type": self.params.get("input_type", "values"),
156
+ }
157
+
158
+ if is_anomaly:
159
+ metadata["direction"] = direction
160
+ metadata["distance"] = float(distance)
161
+
162
+ # Severity: relative distance from bound
163
+ if direction == "below":
164
+ # How far below as percentage of range
165
+ if upper_bound is not None:
166
+ bound_range = upper_bound - lower_bound
167
+ severity = distance / bound_range if bound_range > 0 else float("inf")
168
+ else:
169
+ # No upper bound, just use absolute distance
170
+ severity = distance
171
+ else: # above
172
+ if lower_bound is not None:
173
+ bound_range = upper_bound - lower_bound
174
+ severity = distance / bound_range if bound_range > 0 else float("inf")
175
+ else:
176
+ severity = distance
177
+
178
+ metadata["severity"] = float(severity)
179
+
180
+ results.append(
181
+ DetectionResult(
182
+ timestamp=current_ts,
183
+ value=current_val, # ORIGINAL value
184
+ processed_value=current_processed, # PROCESSED value
185
+ is_anomaly=is_anomaly,
186
+ confidence_lower=lower_bound,
187
+ confidence_upper=upper_bound,
188
+ detection_metadata=metadata,
189
+ )
190
+ )
191
+
192
+ return results
193
+
194
+ def _get_non_default_params(self) -> Dict[str, Any]:
195
+ """
196
+ Get parameters that differ from defaults.
197
+
198
+ Returns all specified bounds plus input_type if not default.
199
+ """
200
+ defaults = {
201
+ "input_type": "values",
202
+ }
203
+ return {
204
+ k: v for k, v in self.params.items()
205
+ if v is not None and v != defaults.get(k)
206
+ }