envlib-ingest-base 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,17 @@
1
+ """envlib-ingest-base: shared toolkit for envlib data-ingest repos.
2
+
3
+ - ``resample``: source-agnostic time-series resampling (exact trapezoidal mean; accumulation sum).
4
+ - ``tsortho``: build + idempotent commons-update of station (ts_ortho) datasets.
5
+ """
6
+
7
+ from envlib_ingest_base.resample import resample
8
+ from envlib_ingest_base.tsortho import build_and_publish, build_local, merge_dataset, update_and_publish
9
+
10
+ __version__ = '0.1.0'
11
+ __all__ = [
12
+ 'build_and_publish',
13
+ 'build_local',
14
+ 'merge_dataset',
15
+ 'resample',
16
+ 'update_and_publish',
17
+ ]
@@ -0,0 +1,341 @@
1
+ """Source-agnostic resampling of irregular / gappy station telemetry to a fixed cadence.
2
+
3
+ Two aggregation kinds, matching the two physical measurement types:
4
+
5
+ - ``'mean'`` (instantaneous signals: river stage, flow) -> an **exact trapezoidal
6
+ time-weighted mean**. The signal is treated as piecewise-linear between consecutive
7
+ observations; each output interval's value is the time-integral of that signal over the
8
+ covered portion of the interval, divided by the covered time. Exact for *any* spacing;
9
+ for regularly-sampled grid-aligned data it equals the trapezoid-rule mean (interval
10
+ endpoints carry half-weight — this coincides with the plain arithmetic mean for linear
11
+ ramps, but is NOT the same as pandas' left-closed ``.resample().mean()``).
12
+
13
+ - ``'sum'`` (accumulations: rainfall) -> a right-closed interval sum. An interval with
14
+ **no** reading is missing (absent from the output), never a fabricated ``0``, while a
15
+ genuinely-reported ``0.0`` is kept. **Values must be per-slot totals** (each reading is
16
+ the accumulation over its own reporting slot) — NOT since-last-report accumulations from
17
+ a totalizing gauge; such feeds must be differenced upstream. Resampling accumulations to
18
+ a cadence FINER than the native reporting interval is not meaningful.
19
+
20
+ Missing data is handled by a two-rule pipeline — the two rules are anchored to two
21
+ different clocks and are NOT derivable from each other:
22
+
23
+ 1. **The gap rule (``max_gap``) — the data's clock.** The estimator for the mean spans
24
+ beyond the aggregation interval (the piecewise-linear model reaches from reading to
25
+ reading regardless of interval boundaries) and must be told where to stop: consecutive
26
+ observations farther apart than ``max_gap`` are a *hole* the signal is never
27
+ interpolated across. By default the threshold adapts per segment as
28
+ ``gap_multiplier x`` the **local median native interval** (a rolling window of
29
+ ``_GAP_WINDOW`` segments), so a station that logged 30-min data twenty years ago and
30
+ 5-min data today gets the right threshold in each era (a global median would misjudge
31
+ one era wholesale). ``max_gap`` may instead be given absolutely (same forms as
32
+ ``freq``). Interpolation honesty is a property of the source's sampling design — never
33
+ of the output frequency, which is why there is deliberately no coupling to ``freq``.
34
+
35
+ 2. **The coverage rule (``min_coverage``) — the output's clock.** Once holes are excluded,
36
+ each output interval is judged by how much of it is genuinely covered: below
37
+ ``min_coverage`` (default **0.75**, in line with common climatological completeness
38
+ practice) the interval is absent rather than summarized from too little data. For the
39
+ mean, coverage is the non-hole time fraction; for the sum, each reading covers its
40
+ accumulation slot (the span back to the previous reading, capped at **one local native
41
+ interval** — a reading after skipped slots covers only its own slot, so losses are never
42
+ credited as measured), and an under-covered interval is absent rather than published as
43
+ a silent undercount. A single-reading series has an unknowable slot and contributes no
44
+ coverage (kept only when ``min_coverage=0``). Missingness in telemetry is not random —
45
+ outages correlate with extreme events — so the default is deliberately conservative. Set
46
+ ``min_coverage=0`` to disable. The threshold comparison is float-based; prefer
47
+ binary-friendly fractions (0.75, 0.5) for exact behavior at the boundary.
48
+
49
+ ``round_to`` declares the source's TRUE timestamp precision (e.g. ``'1min'`` for a station
50
+ whose feed stamps 10:59:59 / 11:00:01 for what is really an 11:00:00 reading). Timestamps
51
+ are rounded to the nearest multiple *before* binning — jittered boundary readings bin into
52
+ the correct interval, and two jittery renderings of the same reading collide and merge via
53
+ the duplicate collapse instead of double-counting. ``None`` (default) = no rounding.
54
+
55
+ Input/output contract:
56
+
57
+ - Time is handled in **microseconds** (``datetime64[us]``) — python ``datetime``'s native
58
+ resolution, with a ±290,000-year range so realistic corrupt dates convert visibly instead
59
+ of overflowing. Sub-microsecond input precision is truncated.
60
+ - ``times``: anything convertible to ``datetime64`` — ISO-format strings, ``datetime64``
61
+ arrays of any unit, python ``datetime`` objects, or pandas DatetimeIndex/Series (tz-aware
62
+ input is converted to UTC-naive). ``None``/``NaT`` entries are dropped. Timezone handling
63
+ is otherwise the caller's job (pass UTC).
64
+ - ``values``: numeric array-like; ``None``/``NaN`` entries are dropped, non-numeric raises.
65
+ - Returns are plain ``(times, values)`` tuples: ascending ``datetime64[us]`` interval-start
66
+ labels (envlib convention) + ``float64`` values. **Empty means ``times.size == 0``** —
67
+ never test the pair itself with ``len()``/truthiness; unpack immediately
68
+ (``t, v = resample(...)``).
69
+ - Both resamplers are **epoch-anchored** (labels are multiples of ``freq`` since
70
+ 1970-01-01), which differs from pandas' start-of-day anchoring only for frequencies that
71
+ do not divide 24 h.
72
+ """
73
+
74
+ from __future__ import annotations
75
+
76
+ import datetime
77
+ import re
78
+
79
+ import numpy as np
80
+
81
+ _MIN_MEAN_POINTS = 2 # need >= 2 observations to form one linear segment
82
+ _GAP_WINDOW = 15 # segments in the rolling local-median window of the adaptive gap rule
83
+ _UNIT_US = {
84
+ 'h': 3_600_000_000,
85
+ 'min': 60_000_000,
86
+ 's': 1_000_000,
87
+ 'D': 86_400_000_000,
88
+ }
89
+ _FREQ_RE = re.compile(r'^([1-9]\d*)?(h|min|s|D)$')
90
+
91
+
92
+ def _freq_us(freq) -> int:
93
+ """A fixed positive interval -> integer microseconds.
94
+
95
+ Accepts ``np.timedelta64`` (fixed units only — calendar M/Y raise), ``datetime.timedelta``,
96
+ or a ``'<n><unit>'`` string with unit in {'h', 'min', 's', 'D'} (n defaults to 1, e.g.
97
+ '1h', '15min', 'D'), plus the envlib CV code ``'day'`` (= '1D'). Anything else — including
98
+ zero, negative, and NaT durations — raises ValueError; never silently mis-parses.
99
+ """
100
+ if isinstance(freq, np.timedelta64):
101
+ if np.datetime_data(freq.dtype)[0] in ('M', 'Y'):
102
+ msg = f'unrecognized freq {freq!r}: calendar units are not fixed durations'
103
+ raise ValueError(msg)
104
+ if np.isnat(freq):
105
+ msg = f'unrecognized freq {freq!r}: NaT is not a duration'
106
+ raise ValueError(msg)
107
+ us = int(freq.astype('timedelta64[us]').astype('int64'))
108
+ elif isinstance(freq, datetime.timedelta):
109
+ us = freq // datetime.timedelta(microseconds=1)
110
+ elif freq == 'day': # the envlib frequency_interval CV code for daily
111
+ us = _UNIT_US['D']
112
+ else:
113
+ m = _FREQ_RE.match(freq) if isinstance(freq, str) else None
114
+ if m is None:
115
+ msg = f"unrecognized freq {freq!r}: expected '<n><unit>', unit in ('h', 'min', 's', 'D'), e.g. '15min'"
116
+ raise ValueError(msg)
117
+ us = int(m.group(1) or 1) * _UNIT_US[m.group(2)]
118
+ if us <= 0:
119
+ msg = f'freq must be a positive duration, got {freq!r}'
120
+ raise ValueError(msg)
121
+ return us
122
+
123
+
124
+ def _to_dt64us(times) -> np.ndarray:
125
+ """Convert to ``datetime64[us]`` (python datetime's native resolution).
126
+
127
+ The ``[us]`` range is ±290k years, so overflow is a non-issue for realistic input;
128
+ ``datetime64`` array casts raise OverflowError beyond it (ISO strings with absurd
129
+ 5+-digit years beyond that range are numpy-parsed and may still wrap — practical
130
+ inputs are unaffected). Sub-microsecond precision in ``datetime64`` input is truncated.
131
+ """
132
+ a = np.asarray(times)
133
+ if a.dtype.kind == 'M':
134
+ return a.astype('datetime64[us]')
135
+ return np.asarray(times, dtype='datetime64[us]')
136
+
137
+
138
+ def _empty() -> tuple[np.ndarray, np.ndarray]:
139
+ return np.empty(0, dtype='datetime64[us]'), np.empty(0, dtype='float64')
140
+
141
+
142
+ def _prep(times, values, round_to=None) -> tuple[np.ndarray, np.ndarray]:
143
+ """Drop NaT/NaN/±inf, round to the declared precision, sort, and collapse duplicate timestamps
144
+ (by mean — rounding runs first, so jittery duplicates collide and merge). Returns int64-us t, float v."""
145
+ dt = _to_dt64us(times)
146
+ v = np.asarray(values, dtype='float64')
147
+ ok = ~np.isnat(dt) & np.isfinite(v)
148
+ t = dt[ok].astype('int64')
149
+ v = v[ok]
150
+ if t.size == 0:
151
+ return t, v
152
+ if round_to is not None:
153
+ r = _freq_us(round_to)
154
+ t = ((t + r // 2) // r) * r # round half up; floor-division-correct pre-1970 too
155
+ order = np.argsort(t, kind='stable')
156
+ t, v = t[order], v[order]
157
+ if t.size > 1 and np.any(np.diff(t) == 0):
158
+ ut, inv = np.unique(t, return_inverse=True)
159
+ v = np.bincount(inv, weights=v) / np.bincount(inv)
160
+ t = ut
161
+ return t, v
162
+
163
+
164
+ def _local_medians(diffs: np.ndarray) -> np.ndarray:
165
+ """Per-segment LOCAL median native interval (rolling window, edge-padded).
166
+
167
+ The local (not global) median makes the adaptive gap rule survive within-station
168
+ regime changes — a station that logged 30-min data for years and 5-min data since
169
+ is judged by each era's own cadence. Falls back to the global median for series
170
+ shorter than the window.
171
+ """
172
+ if diffs.size < _GAP_WINDOW:
173
+ return np.full(diffs.shape, float(np.median(diffs))) if diffs.size else diffs.astype('float64')
174
+ # edges use CENTERED SHRINKING windows (clipped to the real data). The two tempting
175
+ # alternatives both fail a real case: edge-value padding replicates a leading/trailing
176
+ # outage into its own window; a fixed nearest-full-window median swamps a short edge era
177
+ # (< window/2 segments) with the neighboring regime. A centered clipped window keeps the
178
+ # edge segment's own side in the majority in both cases.
179
+ pad = _GAP_WINDOW // 2
180
+ med = np.empty(diffs.shape, dtype='float64')
181
+ interior = np.median(np.lib.stride_tricks.sliding_window_view(diffs, _GAP_WINDOW), axis=1)
182
+ med[pad : pad + interior.size] = interior
183
+ n = diffs.size
184
+ for i in range(pad):
185
+ med[i] = np.median(diffs[: i + pad + 1])
186
+ med[n - 1 - i] = np.median(diffs[n - 1 - i - pad :])
187
+ return med
188
+
189
+
190
+ def _gap_thresholds(diffs: np.ndarray, max_gap, gap_multiplier: float) -> tuple[np.ndarray, np.ndarray]:
191
+ """Per-segment (local median native interval, gap threshold) in us — no freq coupling."""
192
+ med = _local_medians(diffs)
193
+ if max_gap is not None:
194
+ return med, np.full(diffs.shape, float(_freq_us(max_gap)))
195
+ return med, med * gap_multiplier
196
+
197
+
198
+ def _resample_mean(
199
+ times, values, *, freq='1h', max_gap=None, gap_multiplier=3.0, min_coverage=0.75, round_to=None
200
+ ) -> tuple[np.ndarray, np.ndarray]:
201
+ """Exact trapezoidal time-weighted mean of an instantaneous signal onto a fixed cadence.
202
+
203
+ Returns ``(times, values)``: interval-start ``datetime64[us]`` labels + ``float64``
204
+ values; holes are never interpolated across, and intervals covered below
205
+ ``min_coverage`` are absent (see the module docstring for the two-rule pipeline).
206
+ Empty means ``times.size == 0``. ``round_to``: the source's true timestamp precision.
207
+ """
208
+ freq_us = _freq_us(freq)
209
+ t, v = _prep(times, values, round_to)
210
+ if t.size < _MIN_MEAN_POINTS:
211
+ return _empty()
212
+
213
+ seg_dur = np.diff(t) # (n-1,)
214
+ _med, thr = _gap_thresholds(seg_dur, max_gap, gap_multiplier)
215
+ seg_gap = seg_dur > thr
216
+
217
+ # interval boundaries strictly inside (t0, t_last); these become cut points so no
218
+ # sub-segment ever straddles a boundary (=> each sub-segment lies in exactly one interval).
219
+ first_b = (t[0] // freq_us + 1) * freq_us
220
+ last_b = (t[-1] // freq_us) * freq_us
221
+ hb = np.arange(first_b, last_b + 1, freq_us, dtype='int64') if last_b >= first_b else np.empty(0, 'int64')
222
+ if hb.size:
223
+ si = np.clip(np.searchsorted(t, hb, side='right') - 1, 0, t.size - 2)
224
+ frac = (hb - t[si]) / seg_dur[si]
225
+ v_hb = v[si] + (v[si + 1] - v[si]) * frac
226
+ else:
227
+ v_hb = np.empty(0, 'float64')
228
+
229
+ cut_t = np.concatenate([t, hb])
230
+ cut_v = np.concatenate([v, v_hb])
231
+ order = np.argsort(cut_t, kind='stable')
232
+ cut_t, cut_v = cut_t[order], cut_v[order]
233
+
234
+ a_t, b_t = cut_t[:-1], cut_t[1:]
235
+ a_v, b_v = cut_v[:-1], cut_v[1:]
236
+ dur = (b_t - a_t).astype('float64')
237
+ # which original segment each sub-segment sits in (via its midpoint) -> gap status
238
+ mid = a_t + (b_t - a_t) // 2
239
+ sub_seg = np.clip(np.searchsorted(t, mid, side='right') - 1, 0, t.size - 2)
240
+ covered = (~seg_gap[sub_seg]) & (dur > 0)
241
+ if not covered.any():
242
+ return _empty()
243
+
244
+ area = (a_v + b_v) * 0.5 * dur # trapezoidal integral of the linear sub-segment
245
+ hour = a_t // freq_us # sub-segment lies in one interval -> floor(left)
246
+
247
+ hh = hour[covered]
248
+ uh, inv = np.unique(hh, return_inverse=True)
249
+ integ = np.bincount(inv, weights=area[covered])
250
+ cov = np.bincount(inv, weights=dur[covered])
251
+ val = integ / cov
252
+ keep = cov >= min_coverage * freq_us
253
+ return (uh[keep] * freq_us).astype('datetime64[us]'), val[keep]
254
+
255
+
256
+ def _resample_sum(
257
+ times, values, *, freq='1h', max_gap=None, gap_multiplier=3.0, min_coverage=0.75, round_to=None
258
+ ) -> tuple[np.ndarray, np.ndarray]:
259
+ """Right-closed interval sum of an accumulation signal (interval-start labels).
260
+
261
+ A reading is attributed to the interval it *closes* (a reading exactly on a boundary
262
+ belongs to the interval ending there) — which is why ``round_to`` matters here: a
263
+ boundary reading jittered to 11:00:01 would otherwise sum into the wrong hour.
264
+
265
+ Coverage: each reading covers its accumulation slot — the span back to the previous
266
+ reading, capped at ONE local native interval (values are per-slot totals: a reading
267
+ after skipped slots covers only its own slot, so losses are never credited as measured;
268
+ see the module docstring). An interval covered below ``min_coverage`` is absent rather
269
+ than published as a silent undercount (an hour holding 8 of its 12 five-minute slots is
270
+ not an hourly total). A single-reading series has an unknowable slot and contributes no
271
+ coverage (kept only when ``min_coverage=0``). An interval with no reading at all is
272
+ absent — never a fabricated ``0`` — while a reported ``0.0`` counts as full coverage
273
+ of its slot and is kept.
274
+
275
+ Duplicate timestamps collapse **by mean** before summing (the web-service-retry
276
+ failure mode: the same reading served twice must not double-count); with ``round_to``,
277
+ jittery near-duplicates collide onto the same timestamp first and merge the same way.
278
+ Bins are epoch-anchored (see the module docstring).
279
+ """
280
+ freq_us = _freq_us(freq)
281
+ t, v = _prep(times, values, round_to)
282
+ if t.size == 0:
283
+ return _empty()
284
+ lab = ((t - 1) // freq_us) * freq_us # right-closed, left-labeled
285
+
286
+ if t.size == 1:
287
+ # a lone reading's slot is unknowable: it contributes NO coverage (kept only when
288
+ # min_coverage == 0) — assuming a full interval would fabricate completeness while
289
+ # a two-reading series gets honestly judged
290
+ spans = np.array([0.0])
291
+ else:
292
+ diffs = np.diff(t)
293
+ med, _thr = _gap_thresholds(diffs, max_gap, gap_multiplier)
294
+ d = diffs.astype('float64')
295
+ # each reading covers AT MOST one local native slot: crediting the full gap back
296
+ # would count skipped slots as measured (an hour missing every third rain slot
297
+ # would publish as complete). min(d, med) also caps post-hole readings for free.
298
+ spans = np.concatenate([[float(med[0])], np.minimum(d, med)])
299
+
300
+ ul, inv = np.unique(lab, return_inverse=True)
301
+ sums = np.bincount(inv, weights=v)
302
+ cov = np.minimum(np.bincount(inv, weights=spans), float(freq_us))
303
+ keep = cov >= min_coverage * freq_us
304
+ return ul[keep].astype('datetime64[us]'), sums[keep]
305
+
306
+
307
+ _STATISTICS = {'mean': _resample_mean, 'sum': _resample_sum}
308
+
309
+
310
+ def resample(
311
+ times, values, *, statistic='mean', freq='1h', max_gap=None, gap_multiplier=3.0, min_coverage=0.75, round_to=None
312
+ ) -> tuple[np.ndarray, np.ndarray]:
313
+ """Resample one station's raw telemetry to a fixed cadence.
314
+
315
+ ``statistic`` selects both the aggregation and the signal model behind it — pass the
316
+ dataset's envlib ``aggregation_statistic`` metadata value, so the declared identity and
317
+ the computation can never drift:
318
+
319
+ - ``'mean'`` — instantaneous signals (river stage, flow): the exact trapezoidal
320
+ time-weighted mean of the piecewise-linear signal.
321
+ - ``'sum'`` — accumulations (rainfall): the right-closed per-slot-total sum.
322
+
323
+ Future statistics of the instantaneous signal (median/min/max share its whole segment
324
+ machinery, differing only in the final reduction) will slot in here without an API
325
+ change. The two-rule missing-data pipeline (module docstring) applies to every
326
+ statistic. Returns the standard ``(times, values)`` tuple.
327
+ """
328
+ try:
329
+ fn = _STATISTICS[statistic]
330
+ except (KeyError, TypeError) as e:
331
+ msg = f'unsupported statistic {statistic!r}: supported {tuple(_STATISTICS)}'
332
+ raise ValueError(msg) from e
333
+ return fn(
334
+ times,
335
+ values,
336
+ freq=freq,
337
+ max_gap=max_gap,
338
+ gap_multiplier=gap_multiplier,
339
+ min_coverage=min_coverage,
340
+ round_to=round_to,
341
+ )