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.
Files changed (31) hide show
  1. {downsampler-0.3.0/src/downsampler.egg-info → downsampler-0.4.0}/PKG-INFO +1 -1
  2. {downsampler-0.3.0 → downsampler-0.4.0}/pyproject.toml +1 -1
  3. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/__init__.py +3 -1
  4. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/gaps.py +93 -28
  5. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/lttb.py +57 -38
  6. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/m4.py +13 -2
  7. {downsampler-0.3.0 → downsampler-0.4.0/src/downsampler.egg-info}/PKG-INFO +1 -1
  8. {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_gaps.py +55 -13
  9. {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_lttb.py +120 -7
  10. {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_m4.py +3 -2
  11. {downsampler-0.3.0 → downsampler-0.4.0}/LICENSE +0 -0
  12. {downsampler-0.3.0 → downsampler-0.4.0}/README.md +0 -0
  13. {downsampler-0.3.0 → downsampler-0.4.0}/setup.cfg +0 -0
  14. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/aggregators.py +0 -0
  15. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/config.py +0 -0
  16. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/core.py +0 -0
  17. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/edges.py +0 -0
  18. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/fidelity/__init__.py +0 -0
  19. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/fidelity/comparison.py +0 -0
  20. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/fidelity/metrics.py +0 -0
  21. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/ranged.py +0 -0
  22. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler/utils.py +0 -0
  23. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler.egg-info/SOURCES.txt +0 -0
  24. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler.egg-info/dependency_links.txt +0 -0
  25. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler.egg-info/requires.txt +0 -0
  26. {downsampler-0.3.0 → downsampler-0.4.0}/src/downsampler.egg-info/top_level.txt +0 -0
  27. {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_aggregators.py +0 -0
  28. {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_core.py +0 -0
  29. {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_edges.py +0 -0
  30. {downsampler-0.3.0 → downsampler-0.4.0}/tests/test_fidelity.py +0 -0
  31. {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.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.3.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.2.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
- new_rows = []
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
- t_before = df.index[i - 1]
363
- t_after = df.index[i]
364
- # Generate timestamps at source_cadence intervals within the gap
365
- new_times = pd.date_range(
366
- start=t_before + source_cadence,
367
- end=t_after - source_cadence * 0.5, # don't duplicate t_after
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
- if len(new_times) == 0:
371
- continue
372
-
373
- new_ts_float = new_times.astype(np.int64).astype(np.float64)
374
- row_data = {col: np.interp(new_ts_float, orig_timestamps, df[col].values)
375
- for col in numeric_cols}
376
- chunk = pd.DataFrame(row_data, index=new_times)
377
- # Add non-numeric columns as NaN (they can't be interpolated)
378
- for col in df.columns:
379
- if col not in numeric_cols:
380
- chunk[col] = np.nan
381
- new_rows.append(chunk)
382
-
383
- if not new_rows:
384
- return df.copy()
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
- return pd.concat([df] + new_rows).sort_index()
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 split_at_gaps, interpolate_small_gaps, concatenate_with_gap_markers
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
- return pd.DataFrame(columns=df_in.columns)
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 pd.DataFrame(columns=df_in.columns)
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 include.
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, y_down = lttbc.downsample(
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
- # Clean up
176
- df_resampled = df_resampled.drop(['time_num'], axis=1)
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 df_resampled
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 split_at_gaps, concatenate_with_gap_markers
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
- return pd.DataFrame(columns=df_in.columns)
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.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 test_preserves_original_nans(self):
243
- """Test that pre-existing NaN values are not overwritten."""
244
- times = pd.date_range('2024-01-01', periods=5, freq='1min')
245
- df = pd.DataFrame(
246
- {'value': [0.0, np.nan, 2.0, 3.0, 4.0]},
247
- index=times,
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
- result = interpolate_small_gaps(
251
- df,
252
- gap_threshold=pd.Timedelta('10min'),
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
- # Original NaN should still be NaN
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
- # Should return empty or minimal result
85
- assert len(result) == 0
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 test_all_nan_target_returns_empty(self):
215
- """A frame whose target column is entirely NaN yields no output."""
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; interpolation across
240
- # the hole must bridge it rather than propagate NaN.
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
- # Should return empty result
158
- assert len(result) == 0
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