telemetry-anomdet 0.1.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.
@@ -0,0 +1,158 @@
1
+ # src/telemetry_anomdet/models/unsupervised/pca.py
2
+
3
+ """
4
+ PCA based anomaly detection.
5
+
6
+ This model learns a low dimensional subspace of nominal telemetry features
7
+ using Principal Component Analysis (PCA). Anomaly scores are computed as
8
+ the reconstruction error when projecting samples into the PCA subspace
9
+ and back into the original space.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import numpy as np
15
+ from sklearn.decomposition import PCA
16
+ from sklearn.preprocessing import StandardScaler
17
+
18
+ from ...feature_extraction import features
19
+ from ..base import BaseDetector
20
+
21
+
22
+ class PCAAnomaly(BaseDetector):
23
+ """
24
+ PCA-based anomaly detector.
25
+
26
+ Accepts 3D windowed input (n_windows, window_size, n_features) and
27
+ flattens internally via features_stat() before fitting PCA. The caller
28
+ never needs to manage this conversion.
29
+
30
+ Parameters
31
+ ----------
32
+ n_components : int or None, default=None
33
+ Number of principal components to retain. If None, all components
34
+ are kept. Choose a value that retains your desired fraction of
35
+ variance — inspect ``model.explained_variance_ratio_`` after fitting.
36
+ scale : bool, default=True
37
+ Apply StandardScaler before PCA. Recommended when telemetry channels
38
+ differ significantly in scale (e.g. voltage vs. temperature).
39
+ percentile : float, default = 95.0
40
+ Percentile of training reconstruction errors used to set the default
41
+ anomaly threshold. 95.0 means the top 5% most anomalous training
42
+ windows are labelled as anomalies.
43
+
44
+ Attributes (set after fit)
45
+ --------------------------
46
+ decision_scores_ : np.ndarray, shape (n_windows,)
47
+ Reconstruction errors on training data.
48
+ threshold_ : float
49
+ Default anomaly cutoff derived from training scores at ``percentile``.
50
+ labels_ : np.ndarray, shape (n_windows,)
51
+ Binary anomaly labels on training data. 0 = normal, 1 = anomaly.
52
+ model : sklearn.decomposition.PCA
53
+ Fitted PCA instance.
54
+ scaler : sklearn.preprocessing.StandardScaler or None
55
+ Fitted scaler when ``scale = True``, otherwise None.
56
+ """
57
+
58
+ def __init__(
59
+ self,
60
+ n_components: int | None = None,
61
+ scale: bool = True,
62
+ percentile: float = 95.0,
63
+ ):
64
+ super().__init__(percentile=percentile)
65
+ self.n_components = n_components
66
+ self.scale = scale
67
+
68
+ # Fit artifacts: set in fit()
69
+ self.model: PCA | None = None
70
+ self.scaler: StandardScaler | None = None
71
+
72
+ # ---- helpers ----
73
+ def _flatten(self, X: np.ndarray) -> np.ndarray:
74
+ """
75
+ Flatten 3D windowed tensor to 2D feature matrix via features_stat().
76
+
77
+ Parameters
78
+ ----------
79
+ X : np.ndarray, shape (n_windows, window_size, n_features)
80
+
81
+ Returns
82
+ -------
83
+ X2d : np.ndarray, shape (n_windows, n_features * 6)
84
+ Statistical features per window: mean, std, min, max, median, slope.
85
+ """
86
+ return features.features_stat(X)
87
+
88
+ def _scale_fit(self, X2d: np.ndarray) -> np.ndarray:
89
+ if self.scale:
90
+ self.scaler = StandardScaler()
91
+ return self.scaler.fit_transform(X2d)
92
+ self.scaler = None
93
+ return X2d
94
+
95
+ def _scale_transform(self, X2d: np.ndarray) -> np.ndarray:
96
+ if self.scale:
97
+ if self.scaler is None:
98
+ raise RuntimeError("Scaler is not fitted. Was the model fitted with scale = True?")
99
+ return self.scaler.transform(X2d)
100
+ return X2d
101
+
102
+ def _reconstruction_error(self, Xs: np.ndarray) -> np.ndarray:
103
+ """Project into PCA subspace and compute per-window reconstruction error."""
104
+ Z = self.model.transform(Xs)
105
+ X_recon = self.model.inverse_transform(Z)
106
+ return np.sum((Xs - X_recon) ** 2, axis=1)
107
+
108
+ def fit(self, X: np.ndarray, y: np.ndarray | None = None) -> PCAAnomaly:
109
+ """
110
+ Fit PCA on nominal telemetry windows.
111
+
112
+ Parameters
113
+ ----------
114
+ X : np.ndarray, shape (n_windows, window_size, n_features)
115
+ Windowed telemetry tensor from windowify().
116
+ y : ignored
117
+ Present for API consistency.
118
+
119
+ Returns
120
+ -------
121
+ self : PCAAnomaly
122
+ """
123
+ X = self._validate_X(X)
124
+ X2d = self._flatten(X)
125
+ Xs = self._scale_fit(X2d)
126
+
127
+ self.model = PCA(n_components=self.n_components)
128
+ self.model.fit(Xs)
129
+
130
+ scores = self._reconstruction_error(Xs)
131
+ self._set_post_fit(scores)
132
+ return self
133
+
134
+ def decision_function(self, X: np.ndarray) -> np.ndarray:
135
+ """
136
+ Compute reconstruction error for each window.
137
+
138
+ Parameters
139
+ ----------
140
+ X : np.ndarray, shape (n_windows, window_size, n_features)
141
+
142
+ Returns
143
+ -------
144
+ scores : np.ndarray, shape (n_windows,)
145
+ Reconstruction errors. Higher = more anomalous.
146
+ """
147
+ self._require_fit()
148
+ X = self._validate_X(X)
149
+ X2d = self._flatten(X)
150
+ Xs = self._scale_transform(X2d)
151
+ return self._reconstruction_error(Xs)
152
+
153
+ def _get_params(self) -> dict:
154
+ return {
155
+ "n_components": self.n_components,
156
+ "scale": self.scale,
157
+ "percentile": self.percentile,
158
+ }
@@ -0,0 +1,19 @@
1
+ from .preprocessing import (
2
+ clean,
3
+ dedupe,
4
+ integrity_check,
5
+ interpolate_gaps,
6
+ normalize_fit,
7
+ pipeline,
8
+ resample,
9
+ )
10
+
11
+ __all__ = [
12
+ "clean",
13
+ "dedupe",
14
+ "integrity_check",
15
+ "resample",
16
+ "interpolate_gaps",
17
+ "normalize_fit",
18
+ "pipeline",
19
+ ]
@@ -0,0 +1,330 @@
1
+ # telemetry_anomdet/preprocessing.py
2
+
3
+ """
4
+ Preprocessing utilities for telemetry data.
5
+
6
+ This module handles data cleaning, normalization,
7
+ and other transformations before feature extraction.
8
+ """
9
+
10
+ # Long form telemetry means one observation per row, wide means all variables get their own column, with timestamps as the index
11
+
12
+ import fnmatch
13
+
14
+ import pandas as pd
15
+
16
+ # Canonical column names used throughout the preprocessing pipeline
17
+ _TS, _VAR, _VAL = "timestamp", "variable", "value"
18
+
19
+
20
+ def clean(df: pd.DataFrame, *, physical_bounds=None) -> pd.DataFrame:
21
+ """
22
+ Remove non existant values, non numeric readings, and physically impossible sensor values.
23
+
24
+ Arguments:
25
+ df (pd.DataFrame): Long form telemetry data with columns ['timestamp', 'variable', 'value'].
26
+ physical_bounds (dict, optional): Mapping of variable names or patterns to (min, max) valid ranges.
27
+ Example: {'Battery_Voltage': (0, 20), 'Battery_Temp': (-40, 85)}.
28
+ Returns:
29
+ pd.DataFrame: Cleaned dataset.
30
+ """
31
+
32
+ # Work on a copy
33
+ df = df.copy()
34
+
35
+ # Drop rows with missing core fields
36
+ df = df.dropna(subset=[_TS, _VAR, _VAL])
37
+
38
+ # Apply physical bounds
39
+ if physical_bounds:
40
+ # Mask that marks all rows as valid
41
+ mask = pd.Series(True, index=df.index)
42
+
43
+ # Loop over each variable pattern and its allowed (min, max) range
44
+ for pattern, bounds in physical_bounds.items():
45
+ if bounds is None:
46
+ # Skip None bounds
47
+ continue
48
+
49
+ # Bounds must be a 2 element tuple/list (min/max)
50
+ if not (isinstance(bounds, (tuple, list)) and len(bounds) == 2):
51
+ raise ValueError("Must be (min, max)")
52
+
53
+ lower, upper = bounds
54
+
55
+ # Select rows whose variable name matches the given pattern.
56
+ # Bind `pattern` as a default arg so the closure captures this
57
+ # iteration's value, not the loop variable (ruff B023).
58
+ sel = df[_VAR].map(lambda v, pattern=pattern: fnmatch.fnmatch(v, pattern))
59
+
60
+ # Mask out rows that fall below or above the valid range
61
+ if lower is not None:
62
+ mask &= ~(sel & (df[_VAL] < float(lower)))
63
+ if upper is not None:
64
+ mask &= ~(sel & (df[_VAL] > float(upper)))
65
+
66
+ # Apply the mask incrementally
67
+ df = df[mask]
68
+
69
+ # Deterministic ordering (by timestamp and variable)
70
+ df = df.sort_values([_TS, _VAR]).reset_index(drop=True)
71
+
72
+ return df
73
+
74
+
75
+ def dedupe(df: pd.DataFrame) -> pd.DataFrame:
76
+ """
77
+ Remove duplicate or retransmitted rows.
78
+
79
+ Arguments:
80
+ df (pd.DataFrame): Long form telemetry data with potential duplicates.
81
+ Returns:
82
+ pd.DataFrame: DataFrame with duplicates (timestamp, variable) removed.
83
+ """
84
+
85
+ if not isinstance(df, pd.DataFrame):
86
+ raise TypeError("dedupe() expects a pandas DataFrame")
87
+
88
+ df = df.copy()
89
+
90
+ # Sort so duplicates are grouped together
91
+ df = df.sort_values([_TS, _VAR, _VAL])
92
+
93
+ # Drop exact duplicates
94
+ df = df.drop_duplicates()
95
+
96
+ # Drop retransmits (same timestamps, keep last occurence)
97
+ df = df.drop_duplicates(subset=[_TS, _VAR], keep="last")
98
+
99
+ # Reset index for cleanliness before returning
100
+ return df.reset_index(drop=True)
101
+
102
+
103
+ def integrity_check(
104
+ df: pd.DataFrame, *, require_utc: bool = True, require_sorted: bool = True
105
+ ) -> None:
106
+ """
107
+ Verify timestamp format, timezone, and column consistency.
108
+
109
+ Arguments:
110
+ df (pd.DataFrame): Long form telemetry data.
111
+ require_utc (bool): If True, ensure timestamps are UTC.
112
+ require_sorted (bool): If True, ensure timestamps are sorted ascending.
113
+ Raises:
114
+ ValueError: If schema or ordering fails validation.
115
+ """
116
+
117
+ if not isinstance(df, pd.DataFrame):
118
+ raise TypeError("integrity_check() expects a pandas DataFrame")
119
+
120
+ # Extract timestamp column
121
+ ts = df[_TS]
122
+
123
+ # Ensure datetime (datetime64), if not try to coerce
124
+ if not pd.api.types.is_datetime64_any_dtype(ts):
125
+ try:
126
+ ts = pd.to_datetime(ts, errors="raise", utc=require_utc)
127
+ except Exception as e:
128
+ raise ValueError("'timestamp' must be datetime format") from e
129
+
130
+ # UTC requirement
131
+ if require_utc:
132
+ if ts.dt.tz is None:
133
+ raise ValueError("Timestamps must be timezone aware UTC")
134
+ if str(ts.dt.tz) not in ("UTC", "UTC+00:00", "tzutc()"):
135
+ raise ValueError("Timestamps must be in UTC.")
136
+
137
+ # Sorted requirement (chronological order if required)
138
+ if require_sorted and not ts.is_monotonic_increasing:
139
+ raise ValueError("Timestamps must be ascending.")
140
+
141
+ # 'value' must be numeric
142
+ if not pd.api.types.is_numeric_dtype(df[_VAL]):
143
+ raise ValueError("'value' column must be numeric.")
144
+
145
+ return
146
+
147
+
148
+ def resample(df: pd.DataFrame, *, rule: str = "5s", agg: str = "mean") -> pd.DataFrame:
149
+ """
150
+ Resample irregularly spaced data to a uniform cadence.
151
+
152
+ Arguments:
153
+ df (pd.DataFrame): Long form telemetry data.
154
+ rule (str): Resample frequency ('1s', '5s', '1min').
155
+ agg (str): Aggregation method ('mean', 'median', etc.) when multiple values exist per interval.
156
+ Returns:
157
+ pd.DataFrame: Resampled dataset with regular time intervals.
158
+ """
159
+
160
+ df = df.copy()
161
+
162
+ # Ensure proper timestamp dtype
163
+ df[_TS] = pd.to_datetime(df[_TS])
164
+
165
+ # Pivot to wide form
166
+ wide = df.pivot_table(index=_TS, columns=_VAR, values=_VAL)
167
+
168
+ # Resample
169
+ if agg == "mean":
170
+ wide = wide.resample(rule).mean()
171
+ elif agg == "median":
172
+ wide = wide.resample(rule).median()
173
+ elif agg == "min":
174
+ wide = wide.resample(rule).min()
175
+ elif agg == "max":
176
+ wide = wide.resample(rule).max()
177
+ else:
178
+ raise ValueError(f"Unsupported agg method: {agg}")
179
+
180
+ # Fill gaps with forward-fill then backfill
181
+ # This reduces NaNs in windowing
182
+ wide = wide.ffill().bfill()
183
+
184
+ # Melt back to long form
185
+ long = (
186
+ wide.reset_index()
187
+ .melt(id_vars=[_TS], var_name=_VAR, value_name=_VAL)
188
+ .dropna(subset=[_VAL]) # remove variables missing entirely
189
+ .sort_values(_TS)
190
+ .reset_index(drop=True)
191
+ )
192
+
193
+ return long
194
+
195
+
196
+ def interpolate_gaps(
197
+ df: pd.DataFrame, *, method: str = "ffill", limit: int | None = 1
198
+ ) -> pd.DataFrame:
199
+ """
200
+ Fill small missing gaps to ensure continuous time steps.
201
+
202
+ Arguments:
203
+ df (pd.DataFrame): Resampled telemetry data in long form.
204
+ method (str): Interpolation strategy ('ffill', 'bfill', 'linear', etc.).
205
+ limit (int, optional): Maximum consecutive non existant value steps to fill.
206
+ None means no limit.
207
+ Returns:
208
+ pd.DataFrame: Gap filled dataset in long form.
209
+ """
210
+
211
+ if not isinstance(df, pd.DataFrame):
212
+ raise TypeError("interpolate_gaps() expects a pandas DataFrame")
213
+
214
+ df = df.copy()
215
+ df[_TS] = pd.to_datetime(df[_TS])
216
+
217
+ # Pivot to wide form so each variable is a contiguous time series
218
+ wide = df.pivot_table(index=_TS, columns=_VAR, values=_VAL)
219
+
220
+ # Fill gaps column by column, capped at `limit` consecutive steps
221
+ if method == "ffill":
222
+ wide = wide.ffill(limit=limit)
223
+ elif method == "bfill":
224
+ wide = wide.bfill(limit=limit)
225
+ else:
226
+ # Time-aware interpolation methods (e.g. 'linear', 'time', 'spline')
227
+ try:
228
+ wide = wide.interpolate(method=method, limit=limit, limit_direction="forward")
229
+ except (ValueError, NotImplementedError) as e:
230
+ raise ValueError(f"Unsupported interpolation method: {method}") from e
231
+
232
+ # Melt back to long form, dropping gaps that remained unfilled
233
+ long = (
234
+ wide.reset_index()
235
+ .melt(id_vars=[_TS], var_name=_VAR, value_name=_VAL)
236
+ .dropna(subset=[_VAL])
237
+ .sort_values([_TS, _VAR])
238
+ .reset_index(drop=True)
239
+ )
240
+
241
+ return long
242
+
243
+
244
+ def normalize_fit(df: pd.DataFrame, *, method: str = "zscore") -> dict:
245
+ """
246
+ Compute normalization parameters for each variable.
247
+
248
+ Arguments:
249
+ df (pd.DataFrame): Cleaned telemetry data (usually training subset).
250
+ method (str): Normalization method ('zscore' or 'minmax').
251
+ Returns:
252
+ dict: Mapping {variable: (center, scale)} where the pair is
253
+ (mean, std) for 'zscore' or (min, range) for 'minmax'.
254
+ A scale of 0 (constant variable) is stored as 1.0 so that
255
+ applying the parameters never divides by zero.
256
+ """
257
+
258
+ if not isinstance(df, pd.DataFrame):
259
+ raise TypeError("normalize_fit() expects a pandas DataFrame")
260
+ if method not in ("zscore", "minmax"):
261
+ raise ValueError(f"Unsupported normalization method: {method}")
262
+
263
+ params: dict = {}
264
+
265
+ # Compute per-variable statistics independently
266
+ for variable, group in df.groupby(_VAR):
267
+ values = group[_VAL].astype(float)
268
+
269
+ if method == "zscore":
270
+ center = float(values.mean())
271
+ # Sample std (ddof = 1); guard against constant/single-sample series
272
+ scale = float(values.std(ddof=1))
273
+ else: # minmax
274
+ center = float(values.min())
275
+ scale = float(values.max() - center)
276
+
277
+ # Avoid divide-by-zero for constant variables when params are applied
278
+ if not scale > 0:
279
+ scale = 1.0
280
+
281
+ params[variable] = (center, scale)
282
+
283
+ return params
284
+
285
+
286
+ def pipeline(
287
+ df: pd.DataFrame,
288
+ *,
289
+ physical_bounds: dict | None = None,
290
+ resample_rule: str | None = "5s",
291
+ resample_agg: str = "mean",
292
+ interpolate_method: str = "ffill",
293
+ gap_limit: int | None = 1,
294
+ ) -> pd.DataFrame:
295
+ """
296
+ Execute minimal preprocessing pipeline for this dataset.
297
+
298
+ Steps: clean -> dedupe -> integrity_check -> resample -> interpolate_gaps.
299
+
300
+ Normalization is intentionally kept out of this pipeline. Following
301
+ scikit-learn convention, fitting normalization stats is a separate step:
302
+ call :func:`normalize_fit` on the returned (training) frame and reuse the
303
+ resulting params at inference to prevent leakage.
304
+
305
+ Arguments:
306
+ df (pd.DataFrame): Raw telemetry dataset.
307
+ physical_bounds (dict, optional): Min/max physical limits per variable.
308
+ resample_rule (str): Resampling frequency (default '5s'). None skips resampling.
309
+ resample_agg (str): Aggregation method (default 'mean').
310
+ interpolate_method (str): Gap-fill strategy ('ffill', 'linear', etc.).
311
+ gap_limit (int, optional): Max consecutive steps to interpolate.
312
+ Returns:
313
+ pd.DataFrame: Fully preprocessed dataset.
314
+ """
315
+
316
+ # Remove nulls, non physical readings, and sort data
317
+ df = clean(df, physical_bounds=physical_bounds)
318
+
319
+ # Remove dupes or retransmits
320
+ df = dedupe(df)
321
+
322
+ # Validate schema/ordering before time-based operations
323
+ integrity_check(df, require_utc=False, require_sorted=False)
324
+
325
+ # Resample to a uniform cadence, then fill remaining small gaps
326
+ if resample_rule is not None:
327
+ df = resample(df, rule=resample_rule, agg=resample_agg)
328
+ df = interpolate_gaps(df, method=interpolate_method, limit=gap_limit)
329
+
330
+ return df
File without changes
@@ -0,0 +1,15 @@
1
+ # telemetry_anomdet/version.py
2
+
3
+ """
4
+ Package version.
5
+ """
6
+
7
+ try:
8
+ from importlib.metadata import PackageNotFoundError, version
9
+ except ImportError: # Python < 3.8
10
+ from importlib_metadata import PackageNotFoundError, version # type: ignore[assignment]
11
+
12
+ try:
13
+ __version__ = version("telemetry-anomdet")
14
+ except PackageNotFoundError:
15
+ __version__ = "0.0.0"
@@ -0,0 +1,13 @@
1
+ """
2
+ Visualization utilities for telemetry and anomaly scores.
3
+
4
+ Provides functions for plotting time-series signals and anomaly markers.
5
+ """
6
+
7
+
8
+ def plot_anomalies():
9
+ """
10
+ Placeholder for future visualization functions.
11
+ """
12
+
13
+ pass