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