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.
- telemetry_anomdet/__init__.py +20 -0
- telemetry_anomdet/evaluation.py +167 -0
- telemetry_anomdet/feature_extraction/features.py +200 -0
- telemetry_anomdet/ingest/__init__.py +21 -0
- telemetry_anomdet/ingest/ccsds_loader.py +18 -0
- telemetry_anomdet/ingest/csv_loader.py +133 -0
- telemetry_anomdet/ingest/dataset.py +80 -0
- telemetry_anomdet/ingest/smap.py +213 -0
- telemetry_anomdet/models/__init__.py +13 -0
- telemetry_anomdet/models/base.py +365 -0
- telemetry_anomdet/models/ensemble.py +273 -0
- telemetry_anomdet/models/supervised/__init__.py +7 -0
- telemetry_anomdet/models/unsupervised/__init__.py +12 -0
- telemetry_anomdet/models/unsupervised/gaussian_nb.py +61 -0
- telemetry_anomdet/models/unsupervised/isolation_forest.py +28 -0
- telemetry_anomdet/models/unsupervised/kmeans.py +204 -0
- telemetry_anomdet/models/unsupervised/pca.py +158 -0
- telemetry_anomdet/preprocessing/__init__.py +19 -0
- telemetry_anomdet/preprocessing/preprocessing.py +330 -0
- telemetry_anomdet/py.typed +0 -0
- telemetry_anomdet/version.py +15 -0
- telemetry_anomdet/visualization.py +13 -0
- telemetry_anomdet-0.1.0.dist-info/METADATA +364 -0
- telemetry_anomdet-0.1.0.dist-info/RECORD +27 -0
- telemetry_anomdet-0.1.0.dist-info/WHEEL +5 -0
- telemetry_anomdet-0.1.0.dist-info/licenses/LICENSE +201 -0
- telemetry_anomdet-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# telemetry_anomdet/__init__.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Telemetry Anomaly Detection Toolkit
|
|
5
|
+
|
|
6
|
+
This package provides tools for:
|
|
7
|
+
- Preprocessing telemetry data
|
|
8
|
+
- Extracting features
|
|
9
|
+
- Running unsupervised anomaly detection models
|
|
10
|
+
|
|
11
|
+
Usage:
|
|
12
|
+
import telemetry_anomdet
|
|
13
|
+
# Access modules like telemetry_anomdet.preprocessing
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from .version import __version__
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"__version__",
|
|
20
|
+
]
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# src/telemetry_anomdet/evaluation.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Point-adjusted evaluation metrics for time-series anomaly detection.
|
|
5
|
+
|
|
6
|
+
Point adjustment is the standard SMAP / MSL protocol (Xu et al., 2018): if a
|
|
7
|
+
detector flags any point inside a labeled anomaly segment, the whole segment
|
|
8
|
+
counts as detected. It rewards catching an event even when not every point of
|
|
9
|
+
it is flagged, and makes results comparable to the telemanom and MEMTO
|
|
10
|
+
baselines that report point-adjusted F1.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def point_adjust(pred: Sequence[bool], truth: Sequence[bool]) -> np.ndarray:
|
|
21
|
+
"""
|
|
22
|
+
Apply point adjustment to point-level predictions.
|
|
23
|
+
|
|
24
|
+
For each contiguous anomaly segment in ``truth``, if ``pred`` flags any point
|
|
25
|
+
inside it, the entire segment is marked as predicted.
|
|
26
|
+
|
|
27
|
+
Arguments:
|
|
28
|
+
pred: Point-level boolean predictions.
|
|
29
|
+
truth: Point-level boolean ground truth, same length as ``pred``.
|
|
30
|
+
Returns:
|
|
31
|
+
np.ndarray: The adjusted boolean prediction array.
|
|
32
|
+
"""
|
|
33
|
+
|
|
34
|
+
pred = np.asarray(pred, dtype=bool).copy()
|
|
35
|
+
truth = np.asarray(truth, dtype=bool)
|
|
36
|
+
if pred.shape != truth.shape:
|
|
37
|
+
raise ValueError(f"pred and truth must match: {pred.shape} vs {truth.shape}")
|
|
38
|
+
|
|
39
|
+
n = len(truth)
|
|
40
|
+
i = 0
|
|
41
|
+
while i < n:
|
|
42
|
+
if not truth[i]:
|
|
43
|
+
i += 1
|
|
44
|
+
continue
|
|
45
|
+
j = i
|
|
46
|
+
while j < n and truth[j]:
|
|
47
|
+
j += 1
|
|
48
|
+
if pred[i:j].any():
|
|
49
|
+
pred[i:j] = True
|
|
50
|
+
i = j
|
|
51
|
+
return pred
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def prf(pred: Sequence[bool], truth: Sequence[bool]) -> dict:
|
|
55
|
+
"""
|
|
56
|
+
Precision, recall, and F1 for point-level boolean arrays.
|
|
57
|
+
|
|
58
|
+
Returns:
|
|
59
|
+
dict: {'precision', 'recall', 'f1', 'tp', 'fp', 'fn'}.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
pred = np.asarray(pred, dtype=bool)
|
|
63
|
+
truth = np.asarray(truth, dtype=bool)
|
|
64
|
+
tp = int(np.sum(pred & truth))
|
|
65
|
+
fp = int(np.sum(pred & ~truth))
|
|
66
|
+
fn = int(np.sum(~pred & truth))
|
|
67
|
+
precision = tp / (tp + fp) if (tp + fp) else 0.0
|
|
68
|
+
recall = tp / (tp + fn) if (tp + fn) else 0.0
|
|
69
|
+
f1 = 2 * precision * recall / (precision + recall) if (precision + recall) else 0.0
|
|
70
|
+
return {"precision": precision, "recall": recall, "f1": f1, "tp": tp, "fp": fp, "fn": fn}
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def point_adjusted_f1(pred: Sequence[bool], truth: Sequence[bool]) -> dict:
|
|
74
|
+
"""
|
|
75
|
+
Point-adjusted precision, recall, and F1 (the standard SMAP metric).
|
|
76
|
+
|
|
77
|
+
Equivalent to :func:`prf` computed on :func:`point_adjust` output.
|
|
78
|
+
"""
|
|
79
|
+
|
|
80
|
+
return prf(point_adjust(pred, truth), truth)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def windows_to_points(
|
|
84
|
+
window_flags: Sequence[bool], n_points: int, *, window_size: int, step: int
|
|
85
|
+
) -> np.ndarray:
|
|
86
|
+
"""
|
|
87
|
+
Expand window-level flags to a point-level mask.
|
|
88
|
+
|
|
89
|
+
Point ``t`` is flagged if it falls inside any flagged window. Window ``i``
|
|
90
|
+
spans ``[i*step, i*step + window_size)``, matching ``windowify``.
|
|
91
|
+
|
|
92
|
+
Arguments:
|
|
93
|
+
window_flags: Boolean flag per window.
|
|
94
|
+
n_points: Length of the point-level series the windows came from.
|
|
95
|
+
window_size: Samples per window.
|
|
96
|
+
step: Stride between windows.
|
|
97
|
+
Returns:
|
|
98
|
+
np.ndarray: Boolean mask of length ``n_points``.
|
|
99
|
+
"""
|
|
100
|
+
|
|
101
|
+
window_flags = np.asarray(window_flags, dtype=bool)
|
|
102
|
+
points = np.zeros(int(n_points), dtype=bool)
|
|
103
|
+
for i, flag in enumerate(window_flags):
|
|
104
|
+
if flag:
|
|
105
|
+
s = i * step
|
|
106
|
+
points[s : s + window_size] = True
|
|
107
|
+
return points
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def windows_to_point_scores(
|
|
111
|
+
window_scores: Sequence[float], n_points: int, *, window_size: int, step: int
|
|
112
|
+
) -> np.ndarray:
|
|
113
|
+
"""
|
|
114
|
+
Expand window-level scores to a point-level score array.
|
|
115
|
+
|
|
116
|
+
Each point takes the maximum score among the windows covering it (window
|
|
117
|
+
``i`` spans ``[i*step, i*step + window_size)``). Points not covered by any
|
|
118
|
+
window fall back to the minimum window score.
|
|
119
|
+
|
|
120
|
+
Arguments:
|
|
121
|
+
window_scores: Anomaly score per window (higher = more anomalous).
|
|
122
|
+
n_points: Length of the point-level series.
|
|
123
|
+
window_size: Samples per window.
|
|
124
|
+
step: Stride between windows.
|
|
125
|
+
Returns:
|
|
126
|
+
np.ndarray: Float score array of length ``n_points``.
|
|
127
|
+
"""
|
|
128
|
+
window_scores = np.asarray(window_scores, dtype=float)
|
|
129
|
+
points = np.full(int(n_points), -np.inf)
|
|
130
|
+
for i, s in enumerate(window_scores):
|
|
131
|
+
a = i * step
|
|
132
|
+
points[a : a + window_size] = np.maximum(points[a : a + window_size], s)
|
|
133
|
+
fill = window_scores.min() if window_scores.size else 0.0
|
|
134
|
+
points[~np.isfinite(points)] = fill
|
|
135
|
+
return points
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def best_point_adjusted_f1(
|
|
139
|
+
scores: Sequence[float], truth: Sequence[bool], *, n_thresholds: int = 200
|
|
140
|
+
) -> dict:
|
|
141
|
+
"""
|
|
142
|
+
Best point-adjusted F1 over a threshold sweep on point-level scores.
|
|
143
|
+
|
|
144
|
+
Selects the threshold that maximizes point-adjusted F1. This is the standard
|
|
145
|
+
SMAP / MSL "best F1" protocol used by telemanom and MEMTO, which makes those
|
|
146
|
+
baselines comparable. Note that it selects the threshold using the labels, so
|
|
147
|
+
it should be reported as an oracle-threshold upper bound, not a deployable
|
|
148
|
+
operating point.
|
|
149
|
+
|
|
150
|
+
Arguments:
|
|
151
|
+
scores: Point-level anomaly scores (higher = more anomalous).
|
|
152
|
+
truth: Point-level boolean ground truth.
|
|
153
|
+
n_thresholds: Number of candidate thresholds sampled across the score range.
|
|
154
|
+
Returns:
|
|
155
|
+
dict: The best {'precision', 'recall', 'f1', 'tp', 'fp', 'fn', 'threshold'}.
|
|
156
|
+
"""
|
|
157
|
+
scores = np.asarray(scores, dtype=float)
|
|
158
|
+
truth = np.asarray(truth, dtype=bool)
|
|
159
|
+
candidates = np.unique(np.quantile(scores, np.linspace(0.0, 1.0, n_thresholds)))
|
|
160
|
+
best = {"precision": 0.0, "recall": 0.0, "f1": 0.0, "tp": 0, "fp": 0, "fn": 0}
|
|
161
|
+
best["threshold"] = float("inf")
|
|
162
|
+
for t in candidates:
|
|
163
|
+
m = prf(point_adjust(scores >= t, truth), truth)
|
|
164
|
+
if m["f1"] > best["f1"]:
|
|
165
|
+
m["threshold"] = float(t)
|
|
166
|
+
best = m
|
|
167
|
+
return best
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# telemetry_anomdet/features.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Feature extraction methods for telemetry analysis.
|
|
5
|
+
|
|
6
|
+
This module defines functions to compute relevant
|
|
7
|
+
features from preprocessed telemetry data.
|
|
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 numpy as np
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
# Canonical column names
|
|
16
|
+
_TS, _VAR, _VAL = "timestamp", "variable", "value"
|
|
17
|
+
|
|
18
|
+
_REQURED_COLS = {_TS, _VAR, _VAL}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def pivot_wide(df: pd.DataFrame, *, variables=None) -> pd.DataFrame:
|
|
22
|
+
"""
|
|
23
|
+
Convert long form telemetry data to wide format.
|
|
24
|
+
|
|
25
|
+
Arguments:
|
|
26
|
+
df (pd.DataFrame): Long-form data with ['timestamp','variable','value'].
|
|
27
|
+
variables (list[str], optional): Specific variables to include; all by default.
|
|
28
|
+
Returns:
|
|
29
|
+
pd.DataFrame: Wide table (timestamp index, variables as columns).
|
|
30
|
+
"""
|
|
31
|
+
|
|
32
|
+
if not isinstance(df, pd.DataFrame):
|
|
33
|
+
raise TypeError("pivot_table() expects a pandas DataFrame")
|
|
34
|
+
|
|
35
|
+
missing = _REQURED_COLS - set(df.columns)
|
|
36
|
+
|
|
37
|
+
if missing:
|
|
38
|
+
raise ValueError(f"pivot_wide() missing required columns: {missing}")
|
|
39
|
+
|
|
40
|
+
data = df
|
|
41
|
+
|
|
42
|
+
# (Optional): filter to a subset of variables/sensors
|
|
43
|
+
if variables is not None:
|
|
44
|
+
data = data[data[_VAR].isin(variables)]
|
|
45
|
+
|
|
46
|
+
if data.empty:
|
|
47
|
+
return pd.DataFrame(index=pd.Index([], name=_TS))
|
|
48
|
+
|
|
49
|
+
# Pivot from long to wide
|
|
50
|
+
wide = data.pivot_table(
|
|
51
|
+
index=_TS,
|
|
52
|
+
columns=_VAR,
|
|
53
|
+
values=_VAL,
|
|
54
|
+
aggfunc="last",
|
|
55
|
+
).sort_index()
|
|
56
|
+
|
|
57
|
+
# Drop channels that are entirely NaN
|
|
58
|
+
wide = wide.dropna(axis=1, how="all")
|
|
59
|
+
|
|
60
|
+
# Deterministic column order
|
|
61
|
+
if wide.shape[1] > 0:
|
|
62
|
+
wide = wide.loc[:, sorted(wide.columns)]
|
|
63
|
+
|
|
64
|
+
# Tidy up the labels
|
|
65
|
+
wide.index.name = _TS
|
|
66
|
+
wide.columns.name = None
|
|
67
|
+
|
|
68
|
+
return wide
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def windowify(wide_df: pd.DataFrame, *, window_size: int = 50, step: int = 10) -> np.ndarray:
|
|
72
|
+
"""
|
|
73
|
+
Slice wide form telemetry data into overlapping windows.
|
|
74
|
+
|
|
75
|
+
Arguments:
|
|
76
|
+
wide_df (pd.DataFrame): Wide telemetry table.
|
|
77
|
+
window_size (int): Number of samples per window.
|
|
78
|
+
step (int): Step size between consecutive windows.
|
|
79
|
+
Returns:
|
|
80
|
+
np.ndarray: Array with shape (n_windows, window_size, n_features).
|
|
81
|
+
"""
|
|
82
|
+
|
|
83
|
+
if not isinstance(wide_df, pd.DataFrame):
|
|
84
|
+
raise TypeError("windoify() expects a pandas DataFrame")
|
|
85
|
+
|
|
86
|
+
# Convert to numeric matrix: shape (n_samples, n_features)
|
|
87
|
+
values = wide_df.to_numpy(dtype=float)
|
|
88
|
+
n_samples, n_features = values.shape
|
|
89
|
+
|
|
90
|
+
windows = []
|
|
91
|
+
|
|
92
|
+
# Slide a fixed size window across time axis
|
|
93
|
+
for start in range(0, n_samples - window_size + 1, step):
|
|
94
|
+
end = start + window_size
|
|
95
|
+
windows.append(values[start:end, :])
|
|
96
|
+
|
|
97
|
+
# If not enough data for a single window, return an empty array
|
|
98
|
+
if not windows:
|
|
99
|
+
return np.empty((0, window_size, n_features), dtype=float)
|
|
100
|
+
|
|
101
|
+
# Stack list of (window_size, n_features) into (n_windows, window_size, n_features)
|
|
102
|
+
return np.stack(windows, axis=0)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def features_stat(X3d: np.ndarray) -> np.ndarray:
|
|
106
|
+
"""
|
|
107
|
+
Generate a 2D feature table from windowed telemetry.
|
|
108
|
+
|
|
109
|
+
Arguments:
|
|
110
|
+
X3d (np.ndarray): Windowed telemetry tensor with shape
|
|
111
|
+
(n_windows, window_size, n_features), typically produced by
|
|
112
|
+
`make_feature_table()`.
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
np.ndarray:
|
|
116
|
+
Shape (n_windows, n_features * 4).
|
|
117
|
+
For each window and each variable, this function computes
|
|
118
|
+
simple statistical features (mean, standard deviation, min,
|
|
119
|
+
and max) along the time axis and concatenates them into a
|
|
120
|
+
flat feature vector ready for downstream modeling.
|
|
121
|
+
"""
|
|
122
|
+
|
|
123
|
+
X3d = np.asarray(X3d)
|
|
124
|
+
|
|
125
|
+
if X3d.ndim != 3:
|
|
126
|
+
raise ValueError(
|
|
127
|
+
f"features_stat() expects a 3D array "
|
|
128
|
+
f"(n_windows, window_size, n_features), got {X3d.shape}"
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
n_windows, window_size, n_features = X3d.shape
|
|
132
|
+
|
|
133
|
+
if n_windows == 0 or n_features == 0:
|
|
134
|
+
# If no data return an empty 2D array with 0 rows
|
|
135
|
+
return np.empty((0, 0), dtype=float)
|
|
136
|
+
|
|
137
|
+
# Compute stats along the time axis (axis = 1)
|
|
138
|
+
means = np.nanmean(X3d, axis=1) # (n_windows, n_features)
|
|
139
|
+
stds = np.nanstd(X3d, axis=1) # (n_windows, n_features)
|
|
140
|
+
mins = np.nanmin(X3d, axis=1) # (n_windows, n_features)
|
|
141
|
+
maxs = np.nanmax(X3d, axis=1) # (n_windows, n_features)
|
|
142
|
+
medians = np.nanmedian(X3d, axis=1) # (n_windows, n_features)
|
|
143
|
+
|
|
144
|
+
# Linear slope per variable for each window
|
|
145
|
+
times = np.arange(window_size)
|
|
146
|
+
slopes = np.zeros((n_windows, n_features), dtype=float)
|
|
147
|
+
|
|
148
|
+
for i in range(n_windows):
|
|
149
|
+
for j in range(n_features):
|
|
150
|
+
y = X3d[i, :, j]
|
|
151
|
+
mask = np.isfinite(y)
|
|
152
|
+
|
|
153
|
+
# If all NaN or fewer than 2 finite points, slope stays 0
|
|
154
|
+
if mask.sum() < 2:
|
|
155
|
+
slopes[i, j] = 0.0
|
|
156
|
+
else:
|
|
157
|
+
a, b = np.polyfit(times[mask], y[mask], 1)
|
|
158
|
+
slopes[i, j] = a
|
|
159
|
+
|
|
160
|
+
# Concatenate into a single feature vector per window: [mean | std | min | max]
|
|
161
|
+
X_feat = np.concatenate([means, stds, mins, maxs, medians, slopes], axis=1)
|
|
162
|
+
|
|
163
|
+
# Replace any remaining NaN/inf with column means
|
|
164
|
+
col_means = np.nanmean(X_feat, axis=0)
|
|
165
|
+
col_means = np.where(np.isfinite(col_means), col_means, 0.0)
|
|
166
|
+
bad = ~np.isfinite(X_feat)
|
|
167
|
+
if bad.any():
|
|
168
|
+
X_feat[bad] = col_means[np.where(bad)[1]]
|
|
169
|
+
|
|
170
|
+
return X_feat
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def make_feature_table(df: pd.DataFrame, *, variables=None, window_size=50, step=10) -> np.ndarray:
|
|
174
|
+
"""
|
|
175
|
+
Generate a complete feature table from preprocessed telemetry.
|
|
176
|
+
|
|
177
|
+
Arguments:
|
|
178
|
+
df (pd.DataFrame): Preprocessed long-form telemetry data.
|
|
179
|
+
variables (list[str], optional): Subset of sensors to include.
|
|
180
|
+
window_size (int): Window length (default 50 samples).
|
|
181
|
+
step (int): Step size between windows.
|
|
182
|
+
Returns:
|
|
183
|
+
np.ndarray:
|
|
184
|
+
Shape (n_windows, window_size, n_features).
|
|
185
|
+
Ready for downstream modeling or for passing into a
|
|
186
|
+
future features_stat() to get a 2D feature table.
|
|
187
|
+
"""
|
|
188
|
+
|
|
189
|
+
# Convert long form to wide form
|
|
190
|
+
wide = pivot_wide(df, variables=variables)
|
|
191
|
+
|
|
192
|
+
# If no usable data remains, stop early
|
|
193
|
+
if wide.empty:
|
|
194
|
+
return np.empty((0, window_size, 0))
|
|
195
|
+
|
|
196
|
+
# Slice the wide data into fixed length windows
|
|
197
|
+
X3d = windowify(wide, window_size=window_size, step=step)
|
|
198
|
+
|
|
199
|
+
# Return the 3D telemetry tensor ready for feature extraction
|
|
200
|
+
return X3d
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# src/telemetry_anomdet/ingest/__init__.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Ingest subpackage: dataset loaders and parsers.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from .dataset import TelemetryDataset
|
|
8
|
+
from .smap import (
|
|
9
|
+
anomaly_point_mask,
|
|
10
|
+
load_smap,
|
|
11
|
+
load_smap_channel,
|
|
12
|
+
load_smap_labels,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"TelemetryDataset",
|
|
17
|
+
"load_smap",
|
|
18
|
+
"load_smap_channel",
|
|
19
|
+
"load_smap_labels",
|
|
20
|
+
"anomaly_point_mask",
|
|
21
|
+
]
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# src/telemetry_anomdet/ingest/ccsds_loader.pyv
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
|
|
7
|
+
from telemetry_anomdet.ingest.dataset import TelemetryDataset
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def load_from_ccsds(file_or_stream) -> TelemetryDataset:
|
|
11
|
+
"""
|
|
12
|
+
Load telemetry from a CCSDS file or stream.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
df = pd.DataFrame() # Placeholder
|
|
16
|
+
# TODO: Implement CCSDS packet parsing logic
|
|
17
|
+
|
|
18
|
+
return TelemetryDataset(df)
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
# src/telemetry_anomdet/ingest/csv_loader.py
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections.abc import Iterable, Mapping, Sequence
|
|
6
|
+
|
|
7
|
+
import pandas as pd
|
|
8
|
+
|
|
9
|
+
from telemetry_anomdet.ingest.dataset import TelemetryDataset
|
|
10
|
+
|
|
11
|
+
# Canonical columns
|
|
12
|
+
_TS, _VAR, _VAL = "timestamp", "variable", "value"
|
|
13
|
+
|
|
14
|
+
# Common aliases
|
|
15
|
+
DEFAULT_ALIASES: Mapping[str, Iterable[str]] = {
|
|
16
|
+
_TS: {"timestamp", "time", "datetime", "date", "ts"},
|
|
17
|
+
_VAR: {"name", "key", "channel", "sensor", "variable"},
|
|
18
|
+
_VAL: {"value", "reading", "val", "y"},
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_from_csv(
|
|
23
|
+
path: str, *, time_col: str | None = None, value_cols: Sequence[str] | None = None
|
|
24
|
+
) -> TelemetryDataset:
|
|
25
|
+
"""
|
|
26
|
+
Load telemetry from a CSV file.
|
|
27
|
+
|
|
28
|
+
Notes:
|
|
29
|
+
CSV must contain at least colums: timestamp, variable, value. timestamp will be converted to pandas datetime.
|
|
30
|
+
|
|
31
|
+
Arguments:
|
|
32
|
+
path - Path to CSV file.
|
|
33
|
+
time_col - Explicit time column, if ommitted, guessed from aliases
|
|
34
|
+
value_cols - Explicit value columns
|
|
35
|
+
|
|
36
|
+
Returns:
|
|
37
|
+
TelemetryDataset
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
df = pd.read_csv(path)
|
|
41
|
+
aliases_local = DEFAULT_ALIASES
|
|
42
|
+
|
|
43
|
+
# If in long form, normalize column names to canonical
|
|
44
|
+
if is_long_form(df.columns.tolist(), aliases_local):
|
|
45
|
+
ren = {}
|
|
46
|
+
lowmap = {c.lower(): c for c in df.columns}
|
|
47
|
+
ren[lowmap[next(a for a in aliases_local[_TS] if a in lowmap)]] = _TS
|
|
48
|
+
ren[lowmap[next(a for a in aliases_local[_VAR] if a in lowmap)]] = _VAR
|
|
49
|
+
ren[lowmap[next(a for a in aliases_local[_VAL] if a in lowmap)]] = _VAL
|
|
50
|
+
|
|
51
|
+
out = df.rename(columns=ren)
|
|
52
|
+
out = coerce_long(out)
|
|
53
|
+
|
|
54
|
+
return TelemetryDataset(out)
|
|
55
|
+
|
|
56
|
+
# If not in long form, convert from Wide to melt
|
|
57
|
+
tcol = pick_time_column(df.columns.tolist(), time_col=time_col, aliases=aliases_local)
|
|
58
|
+
wide = df.copy()
|
|
59
|
+
wide[tcol] = pd.to_datetime(wide[tcol], errors="coerce", utc=True)
|
|
60
|
+
wide = wide.dropna(subset=[tcol])
|
|
61
|
+
|
|
62
|
+
# Choose measurement columns
|
|
63
|
+
if value_cols:
|
|
64
|
+
missing = [c for c in value_cols if c not in wide.columns]
|
|
65
|
+
if missing:
|
|
66
|
+
raise KeyError(f"value_cols not found: {missing}. Columns: {list(wide.columns)}")
|
|
67
|
+
meas = list(value_cols)
|
|
68
|
+
else:
|
|
69
|
+
meas = [c for c in wide.columns if c != tcol]
|
|
70
|
+
if not meas:
|
|
71
|
+
raise ValueError("No measurement columns found. Provide value_cols=[].")
|
|
72
|
+
|
|
73
|
+
long = wide.melt(id_vars=tcol, value_vars=meas, var_name=_VAR, value_name=_VAL)
|
|
74
|
+
long = long.rename(columns={tcol: _TS})
|
|
75
|
+
long = coerce_long(long)
|
|
76
|
+
|
|
77
|
+
return TelemetryDataset(long)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# Helper for finding datasets with timestamps/variables/values - returns t/f
|
|
81
|
+
def is_long_form(cols: Sequence[str], aliases: Mapping[str, Iterable[str]]) -> bool:
|
|
82
|
+
"""
|
|
83
|
+
Determine whether a CSV is already in "long" form by checking that all three semantic roles appear under some alias.
|
|
84
|
+
"""
|
|
85
|
+
|
|
86
|
+
lc = set(c.lower() for c in cols)
|
|
87
|
+
has_ts = any(a in lc for a in aliases[_TS])
|
|
88
|
+
has_var = any(a in lc for a in aliases[_VAR])
|
|
89
|
+
has_val = any(a in lc for a in aliases[_VAL])
|
|
90
|
+
|
|
91
|
+
return has_ts and has_var and has_val
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def coerce_long(df: pd.DataFrame) -> pd.DataFrame:
|
|
95
|
+
"""
|
|
96
|
+
Final cleanup for a long form DataFrame
|
|
97
|
+
- sort by time, then variable
|
|
98
|
+
- ensure variable is string
|
|
99
|
+
- parse timestamp to utc
|
|
100
|
+
"""
|
|
101
|
+
|
|
102
|
+
df[_TS] = pd.to_datetime(df[_TS], errors="coerce", utc=True)
|
|
103
|
+
df[_VAL] = pd.to_numeric(df[_VAL], errors="coerce")
|
|
104
|
+
df[_VAR] = df[_VAR].astype(str)
|
|
105
|
+
df = df.dropna(subset=[_TS, _VAR, _VAL]).sort_values([_TS, _VAR]).reset_index(drop=True)
|
|
106
|
+
|
|
107
|
+
return df[[_TS, _VAR, _VAL]]
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def pick_time_column(
|
|
111
|
+
cols: Sequence[str], *, time_col: str | None, aliases: Mapping[str, Iterable[str]]
|
|
112
|
+
) -> str:
|
|
113
|
+
"""
|
|
114
|
+
Choose which colun is the time axis for *wide* CSV.
|
|
115
|
+
- if time_col is explicitly provided, verify it exists and return it
|
|
116
|
+
- else, try and match from alias candidates (like: "timestamp", "time")
|
|
117
|
+
- if nothing can be found, raise error listing options and actual columns
|
|
118
|
+
"""
|
|
119
|
+
|
|
120
|
+
if time_col:
|
|
121
|
+
if time_col in cols:
|
|
122
|
+
return time_col
|
|
123
|
+
raise KeyError(f"time_col='{time_col}' not found in columns: {list(cols)}")
|
|
124
|
+
|
|
125
|
+
lowered = {c.lower().strip(): c for c in cols}
|
|
126
|
+
for cand in aliases.get(_TS, ()):
|
|
127
|
+
if cand in lowered:
|
|
128
|
+
return lowered[cand]
|
|
129
|
+
|
|
130
|
+
raise KeyError(
|
|
131
|
+
"No time column found. Provide time_col= or include one of the aliases: "
|
|
132
|
+
f"{aliases.get(_TS)}. Columns seen: {list(cols)}"
|
|
133
|
+
)
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# src/telemetry_anomdet/ingest/dataset.py
|
|
2
|
+
|
|
3
|
+
"""
|
|
4
|
+
Telemetry dataset loader.
|
|
5
|
+
|
|
6
|
+
This module a lightweight class called TelemetryDataset that wraps telemetry data in a consistent form factor for downstream processing.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
|
|
15
|
+
try:
|
|
16
|
+
from typing import Self
|
|
17
|
+
except ImportError:
|
|
18
|
+
from typing_extensions import Self
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass
|
|
22
|
+
class TelemetryDataset:
|
|
23
|
+
"""
|
|
24
|
+
Container for telemetry data.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
# Pandas DataFrame containing telemetry rows.
|
|
28
|
+
_df: pd.DataFrame
|
|
29
|
+
|
|
30
|
+
@classmethod
|
|
31
|
+
def synthetic(cls) -> Self:
|
|
32
|
+
"""
|
|
33
|
+
Generate a small synthetic telemetry dataset for development / testing.
|
|
34
|
+
|
|
35
|
+
Parameters:
|
|
36
|
+
N/A for now.
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
TelemetryDataset
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
df = pd.DataFrame(
|
|
43
|
+
{
|
|
44
|
+
"timestamp": pd.to_datetime(
|
|
45
|
+
["2025-01-01T00:00:00Z", "2025-01-01T00:01:00Z"], utc=True
|
|
46
|
+
),
|
|
47
|
+
"variable": ["temp", "pressure"],
|
|
48
|
+
"value": [25.5, 1013.25],
|
|
49
|
+
}
|
|
50
|
+
).sort_values(["timestamp", "variable"], ignore_index=True)
|
|
51
|
+
|
|
52
|
+
return cls(df)
|
|
53
|
+
|
|
54
|
+
def to_pandas(self) -> pd.DataFrame:
|
|
55
|
+
"""
|
|
56
|
+
Return a copy of the Dataframe to prevent accidental mutation.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
return self._df.copy()
|
|
60
|
+
|
|
61
|
+
@property
|
|
62
|
+
def data(self) -> pd.DataFrame:
|
|
63
|
+
"""
|
|
64
|
+
Direct access to the underlying DataFrame.
|
|
65
|
+
"""
|
|
66
|
+
return self._df
|
|
67
|
+
|
|
68
|
+
def head(self, n: int = 5) -> pd.DataFrame:
|
|
69
|
+
"""
|
|
70
|
+
Head of the underlying DataFrame (copy).
|
|
71
|
+
"""
|
|
72
|
+
|
|
73
|
+
return self._df.head(n).copy()
|
|
74
|
+
|
|
75
|
+
def __len__(self) -> int:
|
|
76
|
+
return len(self._df)
|
|
77
|
+
|
|
78
|
+
def __repr__(self) -> str:
|
|
79
|
+
cols = list(self._df.columns)
|
|
80
|
+
return f"TelemetryDataset(n={len(self)}, cols={cols})"
|