downsampler 0.4.0__tar.gz → 0.4.2__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 (32) hide show
  1. {downsampler-0.4.0/src/downsampler.egg-info → downsampler-0.4.2}/PKG-INFO +1 -1
  2. {downsampler-0.4.0 → downsampler-0.4.2}/pyproject.toml +1 -1
  3. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/__init__.py +9 -1
  4. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/lttb.py +68 -1
  5. {downsampler-0.4.0 → downsampler-0.4.2/src/downsampler.egg-info}/PKG-INFO +1 -1
  6. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler.egg-info/SOURCES.txt +1 -0
  7. downsampler-0.4.2/tests/test_lttb_edge_markers.py +119 -0
  8. {downsampler-0.4.0 → downsampler-0.4.2}/LICENSE +0 -0
  9. {downsampler-0.4.0 → downsampler-0.4.2}/README.md +0 -0
  10. {downsampler-0.4.0 → downsampler-0.4.2}/setup.cfg +0 -0
  11. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/aggregators.py +0 -0
  12. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/config.py +0 -0
  13. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/core.py +0 -0
  14. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/edges.py +0 -0
  15. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/fidelity/__init__.py +0 -0
  16. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/fidelity/comparison.py +0 -0
  17. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/fidelity/metrics.py +0 -0
  18. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/gaps.py +0 -0
  19. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/m4.py +0 -0
  20. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/ranged.py +0 -0
  21. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/utils.py +0 -0
  22. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler.egg-info/dependency_links.txt +0 -0
  23. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler.egg-info/requires.txt +0 -0
  24. {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler.egg-info/top_level.txt +0 -0
  25. {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_aggregators.py +0 -0
  26. {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_core.py +0 -0
  27. {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_edges.py +0 -0
  28. {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_fidelity.py +0 -0
  29. {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_gaps.py +0 -0
  30. {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_lttb.py +0 -0
  31. {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_m4.py +0 -0
  32. {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_ranged.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: downsampler
3
- Version: 0.4.0
3
+ Version: 0.4.2
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.4.0"
7
+ version = "0.4.2"
8
8
  description = "Timeseries DataFrame downsampling with LTTB, aggregation methods, gap handling, and fidelity testing"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -31,6 +31,8 @@ Example:
31
31
  >>> result = downsample_dataframe(df, target_cadence='5min', config=config)
32
32
  """
33
33
 
34
+ import importlib.metadata as _metadata
35
+
34
36
  from downsampler.config import (
35
37
  AggregationMethod,
36
38
  EdgeHandling,
@@ -66,7 +68,13 @@ from downsampler.ranged import (
66
68
  DataFetcher,
67
69
  )
68
70
 
69
- __version__ = "0.3.1"
71
+ # Single source of truth is pyproject.toml; read it back from the installed
72
+ # distribution so this constant can never drift from the released version
73
+ # (it sat at 0.3.1 through the 0.4.0 release).
74
+ try:
75
+ __version__ = _metadata.version("downsampler")
76
+ except _metadata.PackageNotFoundError: # running from a source tree, not installed
77
+ __version__ = "unknown"
70
78
 
71
79
  __all__ = [
72
80
  # Config
@@ -107,7 +107,74 @@ def downsample_lttb(
107
107
  df_in.index, _output_columns(df_in, target_column, include_columns)
108
108
  )
109
109
 
110
- return concatenate_with_gap_markers(resampled_segments)
110
+ result = concatenate_with_gap_markers(resampled_segments)
111
+ return _mark_uncovered_edges(result, df_in.index, df_valid.index, gap_threshold)
112
+
113
+
114
+ def _mark_uncovered_edges(
115
+ result: pd.DataFrame,
116
+ covered: pd.DatetimeIndex,
117
+ valid: pd.DatetimeIndex,
118
+ gap_threshold: pd.Timedelta,
119
+ offset: pd.Timedelta = pd.Timedelta('0.1s'),
120
+ ) -> pd.DataFrame:
121
+ """Mark a hole that touches the frame's edge instead of its interior.
122
+
123
+ **Tactical, and written to be deleted.** Step 7 of the caller's
124
+ ``docs/plans/gap-representation-claimed-windows.md`` retires this whole
125
+ function once a partition stores its claimed window, at which point the
126
+ exact extent is known and no heuristic is needed. Do not build on it.
127
+
128
+ ``downsample_lttb`` opens with ``dropna``, which collapses the frame's
129
+ *coverage* onto its *valid extent*: a day covering 00:00-23:59 whose data
130
+ stops at 22:01 becomes, to everything downstream, a day that simply ends at
131
+ 22:01. Markers then come only from ``concatenate_with_gap_markers``, which
132
+ inserts them *between* segments -- so a batch with one surviving segment
133
+ emits none anywhere, by construction. A gap straddling midnight is a
134
+ trailing hole in day N and a leading hole in day N+1, and neither is
135
+ "between two segments", so it vanishes and a plot draws a confident
136
+ straight line across twelve missing hours.
137
+
138
+ The padding is the evidence, and it is still in ``covered``. Where the
139
+ frame's coverage runs past its valid data by enough to matter, a marker
140
+ goes just outside the valid extent -- ``first_valid - offset`` /
141
+ ``last_valid + offset``, mirroring the interior convention and staying
142
+ strictly inside the batch window so the pipeline's trim-to-range keeps it.
143
+
144
+ **Why half the threshold.** A hole touching the frame edge is a *lower
145
+ bound* on the true gap; the rest of it lives in the neighbouring batch.
146
+ Testing each side at the full threshold would miss any gap the boundary
147
+ bisects. At ``gap_threshold / 2`` the two halves sum to the threshold, so
148
+ no gap at or above the threshold can escape by straddling.
149
+
150
+ Known residual, accepted: a straddling gap in ``[threshold/2, threshold)``
151
+ whose other side is exactly zero gets marked where an interior gap of the
152
+ same width would be interpolated across. Narrow, and conservative in
153
+ direction -- a break shown where a line could have been drawn.
154
+
155
+ This is not needed for ``downsample_m4``, which never drops NaN rows: a
156
+ padded edge is selected as its own buckets' extrema and reaches the output
157
+ as marker rows already. Measured 2026-09-05, in both the padded and the
158
+ inherited-marker shapes.
159
+ """
160
+ if len(covered) == 0 or len(valid) == 0:
161
+ return result
162
+
163
+ half = gap_threshold / 2
164
+ marker_times = []
165
+ if valid[0] - covered[0] >= half:
166
+ marker_times.append(valid[0] - offset)
167
+ if covered[-1] - valid[-1] >= half:
168
+ marker_times.append(valid[-1] + offset)
169
+
170
+ if not marker_times:
171
+ return result
172
+
173
+ markers = pd.DataFrame(
174
+ {col: [np.nan] * len(marker_times) for col in result.columns},
175
+ index=pd.DatetimeIndex(marker_times, name=result.index.name),
176
+ )
177
+ return pd.concat([result, markers]).sort_index()
111
178
 
112
179
 
113
180
  def _output_columns(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: downsampler
3
- Version: 0.4.0
3
+ Version: 0.4.2
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
@@ -25,5 +25,6 @@ tests/test_edges.py
25
25
  tests/test_fidelity.py
26
26
  tests/test_gaps.py
27
27
  tests/test_lttb.py
28
+ tests/test_lttb_edge_markers.py
28
29
  tests/test_m4.py
29
30
  tests/test_ranged.py
@@ -0,0 +1,119 @@
1
+ """Edge markers for holes that reach a frame's boundary.
2
+
3
+ ``downsample_lttb`` opens with ``dropna``, which collapses a frame's *coverage*
4
+ onto its *valid extent*. A day covering 00:00-23:59 whose data stops at 22:01
5
+ becomes, downstream, a day that simply ends at 22:01 — and since markers are
6
+ inserted only *between* segments, a batch left with one segment emits none at
7
+ all. A gap straddling midnight is a trailing hole in day N and a leading hole
8
+ in day N+1, neither of which is "between two segments", so it disappears and a
9
+ renderer draws a straight line across it.
10
+
11
+ Reported from a live ``ace_mag_rtsw_archive_lttb_PT15M`` plot showing exactly
12
+ that: a confident interpolated line across twelve missing hours on
13
+ 2024-01-03/04. ``_mark_uncovered_edges`` reads the coverage back off the input
14
+ index, which ``dropna`` never touched.
15
+ """
16
+
17
+ import numpy as np
18
+ import pandas as pd
19
+ import pytest
20
+
21
+ from downsampler.lttb import downsample_lttb
22
+
23
+
24
+ def day(start: str, periods: int = 1440) -> pd.DatetimeIndex:
25
+ return pd.date_range(start, periods=periods, freq="1min", tz="UTC")
26
+
27
+
28
+ def padded(index: pd.DatetimeIndex, *nan_spans: tuple[str, str]) -> pd.DataFrame:
29
+ """A NaN-padded master: cadence preserved, invalid samples blanked."""
30
+ df = pd.DataFrame({"value": np.sin(np.arange(len(index)) / 100.0)}, index=index)
31
+ for lo, hi in nan_spans:
32
+ span = (index >= pd.Timestamp(lo, tz="UTC")) & (index < pd.Timestamp(hi, tz="UTC"))
33
+ df.loc[span, "value"] = np.nan
34
+ return df
35
+
36
+
37
+ def markers(result: pd.DataFrame) -> list[pd.Timestamp]:
38
+ return list(result.index[result["value"].isna()])
39
+
40
+
41
+ class TestTheReportedDefect:
42
+ """The real ACE 2024-01-03/04 shape, one day at a time."""
43
+
44
+ def test_trailing_hole_is_marked(self):
45
+ """Day N: valid to 22:01, padded to 23:59. A marker just past the
46
+ valid extent tells the reader the day did not simply end there."""
47
+ df = padded(day("2024-01-03"), ("2024-01-03T22:02", "2024-01-04"))
48
+ result = downsample_lttb(df, target_column="value", target_cadence="PT15M")
49
+ assert markers(result) == [
50
+ pd.Timestamp("2024-01-03T22:01:00.1", tz="UTC")
51
+ ]
52
+
53
+ def test_leading_hole_is_marked(self):
54
+ """Day N+1: padded from midnight, valid from 10:11."""
55
+ df = padded(day("2024-01-04"), ("2024-01-04", "2024-01-04T10:11"))
56
+ result = downsample_lttb(df, target_column="value", target_cadence="PT15M")
57
+ assert markers(result) == [
58
+ pd.Timestamp("2024-01-04T10:10:59.9", tz="UTC")
59
+ ]
60
+
61
+ def test_the_two_halves_compose(self):
62
+ """Read together, the day-batched output declares the same single hole
63
+ the unbatched run does — the invariant the defect broke."""
64
+ whole = padded(
65
+ pd.date_range("2024-01-03", periods=2880, freq="1min", tz="UTC"),
66
+ ("2024-01-03T22:02", "2024-01-04T10:11"),
67
+ )
68
+ one_pass = downsample_lttb(whole, target_column="value", target_cadence="PT15M")
69
+ split = pd.concat([
70
+ downsample_lttb(whole[whole.index.day == d], target_column="value",
71
+ target_cadence="PT15M")
72
+ for d in (3, 4)
73
+ ]).sort_index()
74
+
75
+ def hole(out):
76
+ real = out.index[out["value"].notna()]
77
+ gap = max(zip(real[:-1], real[1:]), key=lambda p: p[1] - p[0])
78
+ return gap, any(gap[0] < m < gap[1] for m in markers(out))
79
+
80
+ assert hole(split) == hole(one_pass)
81
+
82
+
83
+ class TestNoSpuriousMarkers:
84
+ """§2.4 of the plan: a gapless year through the real ladder produced zero
85
+ spurious markers, and that must survive this change."""
86
+
87
+ def test_a_complete_frame_is_not_marked(self):
88
+ df = padded(day("2024-01-03"))
89
+ result = downsample_lttb(df, target_column="value", target_cadence="PT15M")
90
+ assert markers(result) == []
91
+
92
+ @pytest.mark.parametrize("pad_minutes, marked", [
93
+ (1, False), # a single dropped sample is not a gap
94
+ (5, False),
95
+ (14, False), # just under half the 30-minute threshold
96
+ (15, True), # exactly half
97
+ (60, True),
98
+ ])
99
+ def test_half_the_threshold_is_the_cutoff(self, pad_minutes, marked):
100
+ """A hole at the edge is a lower bound on the true gap, the rest being
101
+ in the neighbouring batch. Half the threshold on each side sums to the
102
+ threshold, so no gap at or above it escapes by straddling — and nothing
103
+ below it is marked, which is what keeps small dropouts vanishing on
104
+ zoom-out instead of proliferating markers up the ladder."""
105
+ index = day("2024-01-03")
106
+ cut = index[-pad_minutes]
107
+ df = padded(index, (str(cut), "2024-01-04"))
108
+ result = downsample_lttb(df, target_column="value", target_cadence="PT15M")
109
+ assert bool(markers(result)) is marked
110
+
111
+ def test_a_wholly_invalid_frame_still_takes_the_existing_path(self):
112
+ """``gap_marker_frame`` already brackets that case; the edge rule must
113
+ not double-mark it."""
114
+ df = padded(day("2024-01-03"), ("2024-01-03", "2024-01-04"))
115
+ result = downsample_lttb(df, target_column="value", target_cadence="PT15M")
116
+ assert markers(result) == [
117
+ pd.Timestamp("2024-01-03", tz="UTC"),
118
+ pd.Timestamp("2024-01-03T23:59", tz="UTC"),
119
+ ]
File without changes
File without changes
File without changes