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.
- envlib_ingest_base/__init__.py +17 -0
- envlib_ingest_base/resample.py +341 -0
- envlib_ingest_base/tsortho.py +440 -0
- envlib_ingest_base-0.1.0.dist-info/METADATA +336 -0
- envlib_ingest_base-0.1.0.dist-info/RECORD +7 -0
- envlib_ingest_base-0.1.0.dist-info/WHEEL +4 -0
- envlib_ingest_base-0.1.0.dist-info/licenses/LICENSE +201 -0
|
@@ -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
|
+
)
|