downsampler 0.3.0__tar.gz → 0.4.0__tar.gz
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.
- {downsampler-0.3.0/src/downsampler.egg-info → downsampler-0.4.0}/PKG-INFO +1 -1
- {downsampler-0.3.0 → downsampler-0.4.0}/pyproject.toml +1 -1
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/__init__.py +3 -1
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/gaps.py +93 -28
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/lttb.py +57 -38
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/m4.py +13 -2
- {downsampler-0.3.0 → downsampler-0.4.0/src/downsampler.egg-info}/PKG-INFO +1 -1
- {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_gaps.py +55 -13
- {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_lttb.py +120 -7
- {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_m4.py +3 -2
- {downsampler-0.3.0 → downsampler-0.4.0}/LICENSE +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/README.md +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/setup.cfg +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/aggregators.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/config.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/core.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/edges.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/fidelity/__init__.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/fidelity/comparison.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/fidelity/metrics.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/ranged.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/utils.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler.egg-info/SOURCES.txt +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler.egg-info/dependency_links.txt +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler.egg-info/requires.txt +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler.egg-info/top_level.txt +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_aggregators.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_core.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_edges.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_fidelity.py +0 -0
- {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_ranged.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: downsampler
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Timeseries DataFrame downsampling with LTTB, aggregation methods, gap handling, and fidelity testing
|
|
5
5
|
Author-email: Eelco Doornbos <eelco.doornbos@knmi.nl>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "downsampler"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.4.0"
|
|
8
8
|
description = "Timeseries DataFrame downsampling with LTTB, aggregation methods, gap handling, and fidelity testing"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = "MIT"
|
|
@@ -47,6 +47,7 @@ from downsampler.gaps import (
|
|
|
47
47
|
split_at_gaps,
|
|
48
48
|
wrap_in_nans,
|
|
49
49
|
mark_gaps_in_dataframe,
|
|
50
|
+
gap_marker_frame,
|
|
50
51
|
interpolate_small_gaps,
|
|
51
52
|
concatenate_with_gap_markers,
|
|
52
53
|
)
|
|
@@ -65,7 +66,7 @@ from downsampler.ranged import (
|
|
|
65
66
|
DataFetcher,
|
|
66
67
|
)
|
|
67
68
|
|
|
68
|
-
__version__ = "0.
|
|
69
|
+
__version__ = "0.3.1"
|
|
69
70
|
|
|
70
71
|
__all__ = [
|
|
71
72
|
# Config
|
|
@@ -87,6 +88,7 @@ __all__ = [
|
|
|
87
88
|
"split_at_gaps",
|
|
88
89
|
"wrap_in_nans",
|
|
89
90
|
"mark_gaps_in_dataframe",
|
|
91
|
+
"gap_marker_frame",
|
|
90
92
|
"interpolate_small_gaps",
|
|
91
93
|
"concatenate_with_gap_markers",
|
|
92
94
|
# LTTB
|
|
@@ -331,6 +331,12 @@ def interpolate_small_gaps(
|
|
|
331
331
|
in the original data are preserved — only the newly created rows get
|
|
332
332
|
interpolated values.
|
|
333
333
|
|
|
334
|
+
Each column is interpolated **from its own non-NaN samples**, and any new
|
|
335
|
+
timestamp outside a column's valid-sample range is left NaN (``np.interp``
|
|
336
|
+
``left``/``right`` = NaN, i.e. no edge-clamp and no extrapolation). So a
|
|
337
|
+
column with a hole larger than ``gap_threshold`` keeps an honest gap at the
|
|
338
|
+
filled timestamps rather than borrowing another column's edge value.
|
|
339
|
+
|
|
334
340
|
Args:
|
|
335
341
|
df: DataFrame with DatetimeIndex.
|
|
336
342
|
gap_threshold: Gaps at or above this duration are "real" gaps and
|
|
@@ -349,41 +355,100 @@ def interpolate_small_gaps(
|
|
|
349
355
|
source_cadence = estimate_cadence(df)
|
|
350
356
|
|
|
351
357
|
jitter_threshold = source_cadence * 1.5
|
|
352
|
-
deltas = df.index.to_series().diff()
|
|
353
|
-
|
|
354
358
|
numeric_cols = get_numeric_columns(df)
|
|
355
|
-
# Convert original timestamps to float64 for np.interp
|
|
356
|
-
orig_timestamps = df.index.astype(np.int64).astype(np.float64)
|
|
357
359
|
|
|
358
|
-
|
|
360
|
+
# 1. Row skeleton: existing rows plus synthetic rows inside *index* gaps
|
|
361
|
+
# smaller than gap_threshold, at source_cadence, so LTTB sees a
|
|
362
|
+
# continuous grid across small gaps. Values are filled per-column below.
|
|
363
|
+
deltas = df.index.to_series().diff()
|
|
364
|
+
extra_times: list[pd.Timestamp] = []
|
|
359
365
|
for i in range(1, len(df)):
|
|
360
366
|
delta = deltas.iloc[i]
|
|
361
367
|
if delta > jitter_threshold and delta < gap_threshold:
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
freq=source_cadence,
|
|
368
|
+
extra_times.extend(
|
|
369
|
+
pd.date_range(
|
|
370
|
+
start=df.index[i - 1] + source_cadence,
|
|
371
|
+
end=df.index[i] - source_cadence * 0.5, # don't duplicate t_after
|
|
372
|
+
freq=source_cadence,
|
|
373
|
+
)
|
|
369
374
|
)
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
375
|
+
if extra_times:
|
|
376
|
+
out = df.reindex(df.index.append(pd.DatetimeIndex(extra_times)).sort_values())
|
|
377
|
+
else:
|
|
378
|
+
out = df.copy()
|
|
379
|
+
|
|
380
|
+
# 2. Per-column, fill each column's *own* small gaps from its *own* samples,
|
|
381
|
+
# leaving holes wider than gap_threshold NaN. Interpolation is inside-only
|
|
382
|
+
# (no extrapolation → no edge-clamp) and each column is independent (no
|
|
383
|
+
# cross-column bleed).
|
|
384
|
+
for col in numeric_cols:
|
|
385
|
+
out[col] = _interpolate_column_small_gaps(out[col], gap_threshold)
|
|
386
|
+
|
|
387
|
+
return out
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _interpolate_column_small_gaps(
|
|
391
|
+
s: pd.Series,
|
|
392
|
+
gap_threshold: pd.Timedelta,
|
|
393
|
+
) -> pd.Series:
|
|
394
|
+
"""Time-interpolate a single column's gaps narrower than gap_threshold.
|
|
395
|
+
|
|
396
|
+
Points outside the column's valid range stay NaN (no extrapolation), and
|
|
397
|
+
any run of NaNs whose bounding valid samples are gap_threshold or more
|
|
398
|
+
apart is re-blanked so wide holes remain honest gaps.
|
|
399
|
+
"""
|
|
400
|
+
valid = s.notna()
|
|
401
|
+
if valid.sum() < 2:
|
|
402
|
+
return s
|
|
403
|
+
|
|
404
|
+
filled = s.interpolate(method="time", limit_area="inside")
|
|
405
|
+
|
|
406
|
+
# Re-blank interpolated points that fall inside a wide (>= gap_threshold)
|
|
407
|
+
# hole between two consecutive valid samples.
|
|
408
|
+
idx = s.index
|
|
409
|
+
valid_pos = np.flatnonzero(valid.to_numpy())
|
|
410
|
+
for a, b in zip(valid_pos[:-1], valid_pos[1:]):
|
|
411
|
+
if b > a + 1 and (idx[b] - idx[a]) >= gap_threshold:
|
|
412
|
+
filled.iloc[a + 1:b] = np.nan
|
|
413
|
+
|
|
414
|
+
return filled
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def gap_marker_frame(
|
|
418
|
+
index: pd.DatetimeIndex,
|
|
419
|
+
columns: list[str],
|
|
420
|
+
) -> pd.DataFrame:
|
|
421
|
+
"""NaN marker rows bracketing a window that yielded no usable points.
|
|
385
422
|
|
|
386
|
-
|
|
423
|
+
Returned in place of an empty frame when the input covers a real time
|
|
424
|
+
window but every sample in it is unusable — e.g. a batch whose target
|
|
425
|
+
column is entirely NaN (fill-encoded), or one where every segment was
|
|
426
|
+
dropped as too short. Returning nothing there would be indistinguishable
|
|
427
|
+
from "this period was never processed": a plot drawn from the stored
|
|
428
|
+
output connects straight across the window, showing a confident straight
|
|
429
|
+
line (or, zoomed out, a quiet period) where the truth is missing data.
|
|
430
|
+
|
|
431
|
+
Marking both ends of the window rather than a single point makes the gap's
|
|
432
|
+
*extent* explicit, so consumers see where invalid data began and ended
|
|
433
|
+
instead of a single break of unknown width. A one-sample window yields one
|
|
434
|
+
marker row.
|
|
435
|
+
|
|
436
|
+
Args:
|
|
437
|
+
index: DatetimeIndex of the input that produced no output.
|
|
438
|
+
columns: Output columns the caller would otherwise have produced.
|
|
439
|
+
|
|
440
|
+
Returns:
|
|
441
|
+
DataFrame of all-NaN rows at the first and last input timestamps, or
|
|
442
|
+
an empty frame when ``index`` is empty (nothing is knowable then).
|
|
443
|
+
"""
|
|
444
|
+
if len(index) == 0:
|
|
445
|
+
return pd.DataFrame(columns=columns)
|
|
446
|
+
|
|
447
|
+
marker_times = [index[0]] if len(index) == 1 else [index[0], index[-1]]
|
|
448
|
+
return pd.DataFrame(
|
|
449
|
+
{col: [np.nan] * len(marker_times) for col in columns},
|
|
450
|
+
index=pd.DatetimeIndex(marker_times, name=index.name),
|
|
451
|
+
)
|
|
387
452
|
|
|
388
453
|
|
|
389
454
|
def concatenate_with_gap_markers(
|
|
@@ -5,7 +5,12 @@ import numpy as np
|
|
|
5
5
|
import lttbc
|
|
6
6
|
|
|
7
7
|
from downsampler.config import DownsampleConfig, EdgeHandling
|
|
8
|
-
from downsampler.gaps import
|
|
8
|
+
from downsampler.gaps import (
|
|
9
|
+
split_at_gaps,
|
|
10
|
+
interpolate_small_gaps,
|
|
11
|
+
concatenate_with_gap_markers,
|
|
12
|
+
gap_marker_frame,
|
|
13
|
+
)
|
|
9
14
|
from downsampler.edges import apply_edge_handling
|
|
10
15
|
from downsampler.utils import parse_cadence, get_numeric_columns, compute_output_points, filter_columns
|
|
11
16
|
|
|
@@ -71,7 +76,11 @@ def downsample_lttb(
|
|
|
71
76
|
# data exactly like missing-row gaps.
|
|
72
77
|
df_valid = df_in.dropna(subset=[target_column])
|
|
73
78
|
if df_valid.empty:
|
|
74
|
-
|
|
79
|
+
# The window is covered but wholly unusable — mark it rather than
|
|
80
|
+
# returning nothing, so the gap survives into the stored output.
|
|
81
|
+
return gap_marker_frame(
|
|
82
|
+
df_in.index, _output_columns(df_in, target_column, include_columns)
|
|
83
|
+
)
|
|
75
84
|
|
|
76
85
|
# Interpolate small gaps so LTTB receives continuous input
|
|
77
86
|
df_interp = interpolate_small_gaps(df_valid, gap_threshold, source_cadence)
|
|
@@ -94,11 +103,36 @@ def downsample_lttb(
|
|
|
94
103
|
resampled_segments.append(resampled)
|
|
95
104
|
|
|
96
105
|
if not resampled_segments:
|
|
97
|
-
return
|
|
106
|
+
return gap_marker_frame(
|
|
107
|
+
df_in.index, _output_columns(df_in, target_column, include_columns)
|
|
108
|
+
)
|
|
98
109
|
|
|
99
110
|
return concatenate_with_gap_markers(resampled_segments)
|
|
100
111
|
|
|
101
112
|
|
|
113
|
+
def _output_columns(
|
|
114
|
+
df: pd.DataFrame,
|
|
115
|
+
target_column: str,
|
|
116
|
+
include_columns: list[str] | None = None,
|
|
117
|
+
) -> list[str]:
|
|
118
|
+
"""Columns an LTTB run emits: the target plus carried numeric columns.
|
|
119
|
+
|
|
120
|
+
Shared by the normal path and the all-gap marker path so that a batch
|
|
121
|
+
which produced only markers has the same schema as one that produced
|
|
122
|
+
data — a storage writer must not see the column set change with content.
|
|
123
|
+
"""
|
|
124
|
+
keep = [target_column]
|
|
125
|
+
for col in df.columns:
|
|
126
|
+
if col in ('time', 'time_num', target_column):
|
|
127
|
+
continue
|
|
128
|
+
if include_columns is not None and col not in include_columns:
|
|
129
|
+
continue
|
|
130
|
+
if not pd.api.types.is_numeric_dtype(df[col]):
|
|
131
|
+
continue
|
|
132
|
+
keep.append(col)
|
|
133
|
+
return keep
|
|
134
|
+
|
|
135
|
+
|
|
102
136
|
def _lttb_single_segment(
|
|
103
137
|
df: pd.DataFrame,
|
|
104
138
|
target_column: str,
|
|
@@ -107,11 +141,23 @@ def _lttb_single_segment(
|
|
|
107
141
|
) -> pd.DataFrame | None:
|
|
108
142
|
"""Apply LTTB to a single contiguous segment.
|
|
109
143
|
|
|
144
|
+
LTTB selects real source timetags for the *target* column; every other
|
|
145
|
+
requested column is then **selected** at those timetags rather than
|
|
146
|
+
interpolated onto them. ``lttbc.downsample`` returns exact input points,
|
|
147
|
+
so the selected ``time_num`` values map back to the segment rows
|
|
148
|
+
unambiguously, and each ride-along column keeps its real measured value
|
|
149
|
+
(or the small-gap-interpolated value the preprocessing stage placed there).
|
|
150
|
+
This makes it impossible to fabricate a value: earlier code interpolated
|
|
151
|
+
include columns with ``np.interp``, which clamps to a column's edge value
|
|
152
|
+
for any timetag outside that column's valid range — stamping a foreign
|
|
153
|
+
constant across every gap. Selection cannot do that.
|
|
154
|
+
|
|
110
155
|
Args:
|
|
111
156
|
df: Input DataFrame (no gaps).
|
|
112
157
|
target_column: Column to optimize for.
|
|
113
158
|
target_cadence: Target cadence.
|
|
114
|
-
include_columns: Additional columns to
|
|
159
|
+
include_columns: Additional columns to carry (selected, not
|
|
160
|
+
interpolated). If None, all numeric columns are carried.
|
|
115
161
|
|
|
116
162
|
Returns:
|
|
117
163
|
Downsampled DataFrame or None if cannot process.
|
|
@@ -136,46 +182,19 @@ def _lttb_single_segment(
|
|
|
136
182
|
return None
|
|
137
183
|
|
|
138
184
|
# Apply LTTB downsampling (lttbc uses separate x, y arrays)
|
|
139
|
-
x_down,
|
|
185
|
+
x_down, _ = lttbc.downsample(
|
|
140
186
|
df_clean['time_num'].values,
|
|
141
187
|
df_clean[target_column].values,
|
|
142
188
|
n_out
|
|
143
189
|
)
|
|
144
|
-
df_resampled = pd.DataFrame(
|
|
145
|
-
{'time_num': x_down, target_column: y_down}
|
|
146
|
-
)
|
|
147
|
-
|
|
148
|
-
# Reconstruct the datetime index
|
|
149
|
-
df_resampled.index = (
|
|
150
|
-
timeref +
|
|
151
|
-
pd.to_timedelta(df_resampled['time_num'], unit='min')
|
|
152
|
-
)
|
|
153
|
-
|
|
154
|
-
# Interpolate other columns to LTTB-selected time points. Each column
|
|
155
|
-
# interpolates from its own non-NaN samples — np.interp propagates NaN
|
|
156
|
-
# from any NaN in fp, which would blank values near unrelated holes.
|
|
157
|
-
for col in df.columns:
|
|
158
|
-
if col in ['time', 'time_num', target_column]:
|
|
159
|
-
continue
|
|
160
|
-
if include_columns is not None and col not in include_columns:
|
|
161
|
-
continue
|
|
162
|
-
if not pd.api.types.is_numeric_dtype(df[col]):
|
|
163
|
-
continue
|
|
164
|
-
|
|
165
|
-
col_valid = df_work[['time_num', col]].dropna()
|
|
166
|
-
if col_valid.empty:
|
|
167
|
-
df_resampled[col] = np.nan
|
|
168
|
-
else:
|
|
169
|
-
df_resampled[col] = np.interp(
|
|
170
|
-
x=df_resampled['time_num'].values,
|
|
171
|
-
xp=col_valid['time_num'].values,
|
|
172
|
-
fp=col_valid[col].values
|
|
173
|
-
)
|
|
174
190
|
|
|
175
|
-
#
|
|
176
|
-
|
|
191
|
+
# Row-select the segment rows LTTB chose. ``x_down`` are exact members of
|
|
192
|
+
# the input ``time_num`` (lttbc returns real input points), so ``isin``
|
|
193
|
+
# selects exactly those rows — carrying every column's real value and the
|
|
194
|
+
# original datetime index, no interpolation.
|
|
195
|
+
selected = df_work[df_work['time_num'].isin(x_down)]
|
|
177
196
|
|
|
178
|
-
return
|
|
197
|
+
return selected[_output_columns(df, target_column, include_columns)].copy()
|
|
179
198
|
|
|
180
199
|
|
|
181
200
|
def downsample_lttb_with_config(
|
|
@@ -14,7 +14,11 @@ import pandas as pd
|
|
|
14
14
|
import numpy as np
|
|
15
15
|
|
|
16
16
|
from downsampler.config import DownsampleConfig
|
|
17
|
-
from downsampler.gaps import
|
|
17
|
+
from downsampler.gaps import (
|
|
18
|
+
split_at_gaps,
|
|
19
|
+
concatenate_with_gap_markers,
|
|
20
|
+
gap_marker_frame,
|
|
21
|
+
)
|
|
18
22
|
from downsampler.edges import apply_edge_handling
|
|
19
23
|
from downsampler.utils import parse_cadence, get_numeric_columns, compute_output_points, filter_columns
|
|
20
24
|
|
|
@@ -92,7 +96,14 @@ def downsample_m4(
|
|
|
92
96
|
resampled_segments.append(resampled)
|
|
93
97
|
|
|
94
98
|
if not resampled_segments:
|
|
95
|
-
|
|
99
|
+
# Covered but unusable (every segment too short, or all-NaN input):
|
|
100
|
+
# mark the window so the gap survives into the stored output rather
|
|
101
|
+
# than reading as "never processed".
|
|
102
|
+
if include_columns is None:
|
|
103
|
+
columns = get_numeric_columns(df_in)
|
|
104
|
+
else:
|
|
105
|
+
columns = [c for c in include_columns if c in df_in.columns]
|
|
106
|
+
return gap_marker_frame(df_in.index, columns)
|
|
96
107
|
|
|
97
108
|
return concatenate_with_gap_markers(resampled_segments)
|
|
98
109
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: downsampler
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Timeseries DataFrame downsampling with LTTB, aggregation methods, gap handling, and fidelity testing
|
|
5
5
|
Author-email: Eelco Doornbos <eelco.doornbos@knmi.nl>
|
|
6
6
|
License-Expression: MIT
|
|
@@ -10,6 +10,7 @@ from downsampler.gaps import (
|
|
|
10
10
|
split_at_gaps,
|
|
11
11
|
iter_segments,
|
|
12
12
|
wrap_in_nans,
|
|
13
|
+
gap_marker_frame,
|
|
13
14
|
mark_gaps_in_dataframe,
|
|
14
15
|
has_gaps,
|
|
15
16
|
count_gaps,
|
|
@@ -239,22 +240,36 @@ class TestInterpolateSmallGaps:
|
|
|
239
240
|
# Small gap filled (2 new points), large gap left alone
|
|
240
241
|
assert len(result) == 8
|
|
241
242
|
|
|
242
|
-
def
|
|
243
|
-
"""
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
243
|
+
def test_small_inplace_gap_filled_wide_preserved(self):
|
|
244
|
+
"""Small in-place NaN holes are bridged per-column; wide holes stay NaN.
|
|
245
|
+
|
|
246
|
+
(New 0.3.1 contract: small-gap filling is a per-column preprocessing
|
|
247
|
+
step, so a hole narrower than gap_threshold in an otherwise-dense column
|
|
248
|
+
is interpolated from that column's own samples, while a hole at or above
|
|
249
|
+
the threshold is preserved as an honest gap.)
|
|
250
|
+
"""
|
|
251
|
+
times = pd.date_range('2024-01-01', periods=20, freq='1min')
|
|
252
|
+
value = np.arange(20, dtype=float)
|
|
253
|
+
value[3] = np.nan # 1-min hole -> small, should fill
|
|
254
|
+
value[8:15] = np.nan # 7-min hole -> < 10min, should fill
|
|
255
|
+
df = pd.DataFrame({'value': value}, index=times)
|
|
256
|
+
|
|
257
|
+
small = interpolate_small_gaps(
|
|
258
|
+
df, gap_threshold=pd.Timedelta('10min'),
|
|
259
|
+
source_cadence=pd.Timedelta('1min'),
|
|
248
260
|
)
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
261
|
+
# Both holes are narrower than 10 min -> bridged (linear: value == index).
|
|
262
|
+
assert small['value'].isna().sum() == 0
|
|
263
|
+
assert small.iloc[3]['value'] == pytest.approx(3.0)
|
|
264
|
+
assert small.iloc[10]['value'] == pytest.approx(10.0)
|
|
265
|
+
|
|
266
|
+
# With a tighter threshold the 7-min hole is now "wide" -> preserved.
|
|
267
|
+
wide = interpolate_small_gaps(
|
|
268
|
+
df, gap_threshold=pd.Timedelta('5min'),
|
|
253
269
|
source_cadence=pd.Timedelta('1min'),
|
|
254
270
|
)
|
|
255
|
-
|
|
256
|
-
#
|
|
257
|
-
assert np.isnan(result.iloc[1]['value'])
|
|
271
|
+
assert not np.isnan(wide.iloc[3]['value']) # 1-min hole still filled
|
|
272
|
+
assert wide['value'].iloc[8:14].isna().all() # 7-min hole preserved
|
|
258
273
|
|
|
259
274
|
def test_auto_cadence_estimation(self):
|
|
260
275
|
"""Test that source_cadence is auto-estimated when None."""
|
|
@@ -378,3 +393,30 @@ class TestConcatenateWithGapMarkers:
|
|
|
378
393
|
|
|
379
394
|
expected_marker_time = seg1.index[-1] + offset
|
|
380
395
|
assert result.index[1] == expected_marker_time
|
|
396
|
+
|
|
397
|
+
|
|
398
|
+
class TestGapMarkerFrame:
|
|
399
|
+
"""Marker rows standing in for a window that produced no usable output."""
|
|
400
|
+
|
|
401
|
+
def test_marks_both_ends(self):
|
|
402
|
+
idx = pd.date_range('2024-01-01', periods=1440, freq='1min')
|
|
403
|
+
result = gap_marker_frame(idx, ['a', 'b'])
|
|
404
|
+
|
|
405
|
+
assert list(result.columns) == ['a', 'b']
|
|
406
|
+
assert result.isna().all().all()
|
|
407
|
+
assert list(result.index) == [idx[0], idx[-1]]
|
|
408
|
+
|
|
409
|
+
def test_single_sample_window(self):
|
|
410
|
+
idx = pd.date_range('2024-01-01', periods=1, freq='1min')
|
|
411
|
+
assert len(gap_marker_frame(idx, ['a'])) == 1
|
|
412
|
+
|
|
413
|
+
def test_empty_index_yields_empty_frame(self):
|
|
414
|
+
"""No input means no known window — nothing to assert about it."""
|
|
415
|
+
result = gap_marker_frame(pd.DatetimeIndex([]), ['a'])
|
|
416
|
+
|
|
417
|
+
assert len(result) == 0
|
|
418
|
+
assert list(result.columns) == ['a']
|
|
419
|
+
|
|
420
|
+
def test_preserves_index_name(self):
|
|
421
|
+
idx = pd.date_range('2024-01-01', periods=10, freq='1min', name='time')
|
|
422
|
+
assert gap_marker_frame(idx, ['a']).index.name == 'time'
|
|
@@ -81,8 +81,10 @@ class TestDownsampleLttb:
|
|
|
81
81
|
min_points_per_segment=3
|
|
82
82
|
)
|
|
83
83
|
|
|
84
|
-
#
|
|
85
|
-
|
|
84
|
+
# The segment is dropped as too short, but the window it covered is
|
|
85
|
+
# marked rather than silently vanishing.
|
|
86
|
+
assert result['value'].isna().all()
|
|
87
|
+
assert list(result.index) == [small_df.index[0], small_df.index[-1]]
|
|
86
88
|
|
|
87
89
|
|
|
88
90
|
class TestLttbGapHandling:
|
|
@@ -211,15 +213,61 @@ class TestLttbNanEncodedGaps:
|
|
|
211
213
|
assert pd.Timestamp('2024-01-01 00:59') < markers.index[0]
|
|
212
214
|
assert markers.index[0] < pd.Timestamp('2024-01-01 01:45')
|
|
213
215
|
|
|
214
|
-
def
|
|
215
|
-
"""
|
|
216
|
+
def test_all_nan_target_marks_the_window(self):
|
|
217
|
+
"""An all-NaN target marks its window instead of vanishing.
|
|
218
|
+
|
|
219
|
+
Returning nothing would make a covered-but-unusable period
|
|
220
|
+
indistinguishable from one that was never processed, and a plot drawn
|
|
221
|
+
from the stored output would connect straight across it.
|
|
222
|
+
"""
|
|
216
223
|
idx = pd.date_range('2024-01-01', periods=100, freq='1min')
|
|
217
224
|
df = pd.DataFrame({'signal': np.nan, 'other': 1.0}, index=idx)
|
|
218
225
|
|
|
219
226
|
result = downsample_lttb(df, target_column='signal', target_cadence='PT15M')
|
|
220
227
|
|
|
221
|
-
assert len(result) == 0
|
|
222
228
|
assert list(result.columns) == ['signal', 'other']
|
|
229
|
+
assert result.isna().all().all()
|
|
230
|
+
# Both ends marked, so the gap's extent is explicit.
|
|
231
|
+
assert list(result.index) == [idx[0], idx[-1]]
|
|
232
|
+
|
|
233
|
+
def test_all_nan_target_single_sample_marks_once(self):
|
|
234
|
+
"""A one-sample window needs only one marker."""
|
|
235
|
+
idx = pd.date_range('2024-01-01', periods=1, freq='1min')
|
|
236
|
+
df = pd.DataFrame({'signal': [np.nan], 'other': [1.0]}, index=idx)
|
|
237
|
+
|
|
238
|
+
result = downsample_lttb(df, target_column='signal', target_cadence='PT15M')
|
|
239
|
+
|
|
240
|
+
assert len(result) == 1
|
|
241
|
+
assert result.index[0] == idx[0]
|
|
242
|
+
|
|
243
|
+
def test_empty_input_still_returns_empty(self):
|
|
244
|
+
"""With no input there is no window to mark."""
|
|
245
|
+
df = pd.DataFrame(
|
|
246
|
+
{'signal': [], 'other': []},
|
|
247
|
+
index=pd.DatetimeIndex([], name='time'),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
result = downsample_lttb(df, target_column='signal', target_cadence='PT15M')
|
|
251
|
+
|
|
252
|
+
assert len(result) == 0
|
|
253
|
+
|
|
254
|
+
def test_marked_window_matches_normal_batch_schema(self):
|
|
255
|
+
"""A marker-only batch has the same columns as a batch with data.
|
|
256
|
+
|
|
257
|
+
A storage writer must not see the column set change with content.
|
|
258
|
+
"""
|
|
259
|
+
idx = pd.date_range('2024-01-01', periods=100, freq='1min')
|
|
260
|
+
good = pd.DataFrame(
|
|
261
|
+
{'signal': np.sin(np.arange(100) / 5.0), 'other': 1.0, 'label': 'x'},
|
|
262
|
+
index=idx,
|
|
263
|
+
)
|
|
264
|
+
bad = good.copy()
|
|
265
|
+
bad['signal'] = np.nan
|
|
266
|
+
|
|
267
|
+
normal = downsample_lttb(good, target_column='signal', target_cadence='PT15M')
|
|
268
|
+
marked = downsample_lttb(bad, target_column='signal', target_cadence='PT15M')
|
|
269
|
+
|
|
270
|
+
assert list(marked.columns) == list(normal.columns)
|
|
223
271
|
|
|
224
272
|
def test_include_column_own_nan_holes(self):
|
|
225
273
|
"""Include columns interpolate from their own valid samples only."""
|
|
@@ -236,12 +284,77 @@ class TestLttbNanEncodedGaps:
|
|
|
236
284
|
source_cadence=pd.Timedelta('1min'),
|
|
237
285
|
)
|
|
238
286
|
|
|
239
|
-
# 'other' is constant 100 outside its hole;
|
|
240
|
-
#
|
|
287
|
+
# 'other' is constant 100 outside its hole; the small-gap preprocessing
|
|
288
|
+
# bridges it per-column rather than propagating NaN.
|
|
241
289
|
assert result['other'].isna().sum() == 0
|
|
242
290
|
assert (result['other'] == 100.0).all()
|
|
243
291
|
|
|
244
292
|
|
|
293
|
+
class TestLttbIncludeSelection:
|
|
294
|
+
"""Include columns are row-SELECTED at LTTB timetags, never interpolated.
|
|
295
|
+
|
|
296
|
+
Regression coverage for the np.interp edge-clamp bug: interpolating an
|
|
297
|
+
include column onto the target's timetags stamped the column's edge value
|
|
298
|
+
across every timetag outside its valid range (a constant foreign peak at
|
|
299
|
+
each gap). Selection cannot fabricate values.
|
|
300
|
+
"""
|
|
301
|
+
|
|
302
|
+
def test_high_window_does_not_bleed_into_quiet_period(self):
|
|
303
|
+
"""A column valid only in a late high window must not appear earlier."""
|
|
304
|
+
idx = pd.date_range('2024-01-01 00:00', periods=24 * 60, freq='1min')
|
|
305
|
+
signal = 350 + 10 * np.sin(np.arange(len(idx)) / 30.0) # dense all day
|
|
306
|
+
other = np.full(len(idx), np.nan)
|
|
307
|
+
hi = (idx >= '2024-01-01 20:00') & (idx <= '2024-01-01 21:00')
|
|
308
|
+
other[hi] = 602.9 # the ONLY place 'other' has data
|
|
309
|
+
df = pd.DataFrame({'signal': signal, 'other': other}, index=idx)
|
|
310
|
+
|
|
311
|
+
result = downsample_lttb(
|
|
312
|
+
df, target_column='signal', target_cadence='PT5M',
|
|
313
|
+
source_cadence=pd.Timedelta('1min'),
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
# No 'other' value may appear at a timetag outside its valid window,
|
|
317
|
+
# and certainly not the clamped constant 602.9 in the quiet morning.
|
|
318
|
+
nonnull = result['other'].dropna()
|
|
319
|
+
assert (nonnull.index >= pd.Timestamp('2024-01-01 20:00')).all()
|
|
320
|
+
assert (nonnull.index <= pd.Timestamp('2024-01-01 21:00')).all()
|
|
321
|
+
morning = result.loc[result.index < pd.Timestamp('2024-01-01 19:00'), 'other']
|
|
322
|
+
assert morning.isna().all()
|
|
323
|
+
|
|
324
|
+
def test_ridealong_wide_hole_stays_nan(self):
|
|
325
|
+
"""A hole wider than gap_threshold in a ride-along column stays NaN."""
|
|
326
|
+
idx = pd.date_range('2024-01-01', periods=240, freq='1min')
|
|
327
|
+
signal = 500 + 10 * np.sin(np.arange(240) / 20.0) # complete target
|
|
328
|
+
other = np.full(240, 100.0)
|
|
329
|
+
other[60:150] = np.nan # 90-min hole >> 30-min gap_threshold (PT15M)
|
|
330
|
+
df = pd.DataFrame({'signal': signal, 'other': other}, index=idx)
|
|
331
|
+
|
|
332
|
+
result = downsample_lttb(
|
|
333
|
+
df, target_column='signal', target_cadence='PT15M',
|
|
334
|
+
source_cadence=pd.Timedelta('1min'),
|
|
335
|
+
)
|
|
336
|
+
|
|
337
|
+
in_hole = result[(result.index > idx[60]) & (result.index < idx[149])]
|
|
338
|
+
assert not in_hole.empty # target is dense there, so timetags exist
|
|
339
|
+
assert in_hole['other'].isna().all()
|
|
340
|
+
|
|
341
|
+
def test_include_values_are_exact_source_selections(self):
|
|
342
|
+
"""Each carried include value equals the real source sample (not interp)."""
|
|
343
|
+
idx = pd.date_range('2024-01-01', periods=240, freq='1min')
|
|
344
|
+
rng = np.random.default_rng(0)
|
|
345
|
+
signal = 500 + 50 * np.sin(np.arange(240) / 15.0) + rng.normal(0, 3, 240)
|
|
346
|
+
other = 100 + rng.normal(0, 10, 240) # noisy, dense (no gaps)
|
|
347
|
+
df = pd.DataFrame({'signal': signal, 'other': other}, index=idx)
|
|
348
|
+
|
|
349
|
+
result = downsample_lttb(
|
|
350
|
+
df, target_column='signal', target_cadence='PT15M',
|
|
351
|
+
source_cadence=pd.Timedelta('1min'),
|
|
352
|
+
)
|
|
353
|
+
|
|
354
|
+
for t, v in result['other'].dropna().items():
|
|
355
|
+
assert v == pytest.approx(df.loc[t, 'other'])
|
|
356
|
+
|
|
357
|
+
|
|
245
358
|
class TestLttbShortSegments:
|
|
246
359
|
"""Short data islands must survive instead of being dropped."""
|
|
247
360
|
|
|
@@ -154,8 +154,9 @@ class TestDownsampleM4:
|
|
|
154
154
|
min_points_per_segment=3
|
|
155
155
|
)
|
|
156
156
|
|
|
157
|
-
#
|
|
158
|
-
assert
|
|
157
|
+
# The segment is dropped as too short, but its window is marked.
|
|
158
|
+
assert result['value'].isna().all()
|
|
159
|
+
assert len(result) == 2
|
|
159
160
|
|
|
160
161
|
def test_output_has_irregular_timestamps(self, sine_df):
|
|
161
162
|
"""Test that M4 produces irregular timestamps (not aligned to grid)."""
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|