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.
- {downsampler-0.4.0/src/downsampler.egg-info → downsampler-0.4.2}/PKG-INFO +1 -1
- {downsampler-0.4.0 → downsampler-0.4.2}/pyproject.toml +1 -1
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/__init__.py +9 -1
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/lttb.py +68 -1
- {downsampler-0.4.0 → downsampler-0.4.2/src/downsampler.egg-info}/PKG-INFO +1 -1
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler.egg-info/SOURCES.txt +1 -0
- downsampler-0.4.2/tests/test_lttb_edge_markers.py +119 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/LICENSE +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/README.md +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/setup.cfg +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/aggregators.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/config.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/core.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/edges.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/fidelity/__init__.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/fidelity/comparison.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/fidelity/metrics.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/gaps.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/m4.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/ranged.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler/utils.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler.egg-info/dependency_links.txt +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler.egg-info/requires.txt +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/src/downsampler.egg-info/top_level.txt +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_aggregators.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_core.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_edges.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_fidelity.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_gaps.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_lttb.py +0 -0
- {downsampler-0.4.0 → downsampler-0.4.2}/tests/test_m4.py +0 -0
- {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.
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
|
@@ -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
|
|
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
|
|
File without changes
|