@jarenjs/core 0.46.5 → 0.49.2
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.
- package/ARCHITECTURE.md +104 -0
- package/README.md +46 -2
- package/dist/types/dates/civil.d.ts +24 -5
- package/dist/types/dates/index.d.ts +2 -0
- package/dist/types/dates/parse.d.ts +47 -0
- package/dist/types/dates/ticks.d.ts +59 -0
- package/dist/types/math/float64.d.ts +13 -0
- package/dist/types/series/asof.d.ts +88 -0
- package/dist/types/series/bucket.d.ts +206 -0
- package/dist/types/series/downsample.d.ts +53 -0
- package/dist/types/series/index.d.ts +8 -0
- package/dist/types/series/interval-index.d.ts +47 -0
- package/dist/types/series/interval.d.ts +170 -0
- package/dist/types/series/normalize.d.ts +190 -0
- package/dist/types/series/rolling.d.ts +67 -0
- package/dist/types/series/selector.d.ts +29 -0
- package/dist/types/series/zone.d.ts +59 -0
- package/docs/DATES.md +77 -1
- package/docs/SERIES.md +342 -0
- package/package.json +9 -1
- package/src/dates/civil.js +136 -41
- package/src/dates/index.js +4 -0
- package/src/dates/parse.js +410 -0
- package/src/dates/ticks.js +184 -0
- package/src/math/float64.js +29 -0
- package/src/series/asof.js +276 -0
- package/src/series/bucket.js +542 -0
- package/src/series/downsample.js +343 -0
- package/src/series/index.js +86 -0
- package/src/series/interval-index.js +181 -0
- package/src/series/interval.js +374 -0
- package/src/series/normalize.js +330 -0
- package/src/series/rolling.js +229 -0
- package/src/series/selector.js +104 -0
- package/src/series/zone.js +188 -0
|
@@ -0,0 +1,542 @@
|
|
|
1
|
+
//@ts-check
|
|
2
|
+
|
|
3
|
+
//#region Buckets, resampling and fill
|
|
4
|
+
// Two questions that are always asked together and are not the same
|
|
5
|
+
// question. **Bucketing** is "which span does this instant fall in",
|
|
6
|
+
// and it is arithmetic. **Filling** is "what does a span with no
|
|
7
|
+
// readings say", and it is a policy — five of them, none of which is a
|
|
8
|
+
// default worth guessing at.
|
|
9
|
+
//
|
|
10
|
+
// Keeping them apart is the lesson every gapfill implementation
|
|
11
|
+
// eventually learns: an average over an empty hour is not zero, and it
|
|
12
|
+
// is not yesterday's average, and it is not nothing. It is whichever of
|
|
13
|
+
// those the caller asked for, and the aggregate had no opinion.
|
|
14
|
+
//
|
|
15
|
+
// The boundaries themselves come in two flavours, and the difference is
|
|
16
|
+
// physical rather than stylistic:
|
|
17
|
+
//
|
|
18
|
+
// **fixed** `PT15M`, `PT1H`, `P1D`, or a number of milliseconds. The
|
|
19
|
+
// boundary is `origin + k × width`, integer arithmetic all the way
|
|
20
|
+
// down, correct over negative epochs because the division floors.
|
|
21
|
+
//
|
|
22
|
+
// **calendar** `P1M`, `P1Y`, and a whole number of days on a named
|
|
23
|
+
// zone. A month has no width, so the boundary is computed by the
|
|
24
|
+
// calendar kernel from an anchor, and on a named zone it is computed
|
|
25
|
+
// through the caller's provider — which is where a spring-forward
|
|
26
|
+
// midnight that never happened becomes a refusal instead of an hour
|
|
27
|
+
// nobody notices.
|
|
28
|
+
//
|
|
29
|
+
// Nothing here reads a clock: with no explicit `start`/`end` the window
|
|
30
|
+
// is the data's own first and last bucket.
|
|
31
|
+
|
|
32
|
+
import { addToParts } from '../dates/civil.js';
|
|
33
|
+
import { parseDuration, durationToMs } from '../dates/duration.js';
|
|
34
|
+
import { toEpoch, canonicalSeries, lowerBoundTime } from './normalize.js';
|
|
35
|
+
import { requireSpecMembers } from './selector.js';
|
|
36
|
+
import { resolveClock, CLOCK_MEMBERS } from './zone.js';
|
|
37
|
+
|
|
38
|
+
/** @typedef {import('./normalize.js').Sample} Sample */
|
|
39
|
+
/** @typedef {import('./zone.js').Clock} Clock */
|
|
40
|
+
|
|
41
|
+
const MS_PER_DAY = 86400000;
|
|
42
|
+
|
|
43
|
+
/** What a bucket's rows can be reduced to. */
|
|
44
|
+
const AGGREGATES = Object.freeze(['sum', 'mean', 'min', 'max', 'first', 'last', 'count']);
|
|
45
|
+
|
|
46
|
+
/** What an empty bucket says. */
|
|
47
|
+
const FILLS = Object.freeze(['omit', 'null', 'zero', 'locf', 'linear']);
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A compiled bucket ladder: the boundary arithmetic for one `every`,
|
|
51
|
+
* one `origin` and one clock, validated once so a resample never
|
|
52
|
+
* re-reads its own specification.
|
|
53
|
+
* @typedef {Object} CompiledBuckets
|
|
54
|
+
* @property {number | string} every - the width as it was written
|
|
55
|
+
* @property {boolean} calendar - whether boundaries need the calendar
|
|
56
|
+
* @property {string} unit - `'millisecond'`, `'day'` or `'month'`
|
|
57
|
+
* @property {number} amount - the count of that unit
|
|
58
|
+
* @property {number} width - fixed width in milliseconds, or 0 when
|
|
59
|
+
* the ladder is a calendar one
|
|
60
|
+
* @property {string} zone - the clock the boundaries fall on
|
|
61
|
+
* @property {number} origin - the anchor, in epoch milliseconds
|
|
62
|
+
* @property {(index: number) => number} startOf - the boundary at a
|
|
63
|
+
* ladder position; position 0 is `origin`
|
|
64
|
+
* @property {(at: number) => number} indexOf - the ladder position
|
|
65
|
+
* holding an instant
|
|
66
|
+
* @property {(at: number) => number} floor - the boundary of the bucket
|
|
67
|
+
* holding an instant
|
|
68
|
+
*/
|
|
69
|
+
|
|
70
|
+
//#endregion
|
|
71
|
+
|
|
72
|
+
//#region the ladder
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* The width, taken apart once: which family it belongs to, and how much
|
|
76
|
+
* of that family's unit it is.
|
|
77
|
+
* @param {any} every
|
|
78
|
+
* @param {boolean} zoned - whether the clock is a named zone, where a
|
|
79
|
+
* day is a calendar unit rather than 86,400,000 milliseconds
|
|
80
|
+
* @returns {{ calendar: boolean, unit: string, amount: number, width: number }}
|
|
81
|
+
*/
|
|
82
|
+
function requireEvery(every, zoned) {
|
|
83
|
+
if (typeof every === 'number') {
|
|
84
|
+
requirePositiveInteger(every, 'a bucket width in milliseconds');
|
|
85
|
+
return dayOrMillisecond(every, zoned);
|
|
86
|
+
}
|
|
87
|
+
if (typeof every !== 'string')
|
|
88
|
+
throw new TypeError('\'every\' is a positive number of milliseconds or an ISO 8601 duration');
|
|
89
|
+
const parts = parseDuration(every);
|
|
90
|
+
if (parts === null || parts.negative)
|
|
91
|
+
throw new TypeError(`'every' — '${every}' is not a positive ISO 8601 duration`);
|
|
92
|
+
if (parts.years !== 0 || parts.months !== 0) {
|
|
93
|
+
if (parts.weeks !== 0 || parts.days !== 0 || parts.hours !== 0
|
|
94
|
+
|| parts.minutes !== 0 || parts.seconds !== 0) {
|
|
95
|
+
throw new TypeError(`'every' — '${every}' mixes calendar and fixed units; a bucket ladder`
|
|
96
|
+
+ ' is one family, because a month and an hour do not share a boundary');
|
|
97
|
+
}
|
|
98
|
+
const months = parts.years * 12 + parts.months;
|
|
99
|
+
requirePositiveInteger(months, `'every' — '${every}' in months`);
|
|
100
|
+
return { calendar: true, unit: 'month', amount: months, width: 0 };
|
|
101
|
+
}
|
|
102
|
+
const ms = durationToMs(parts);
|
|
103
|
+
requirePositiveInteger(ms, `'every' — '${every}' in milliseconds`);
|
|
104
|
+
return dayOrMillisecond(ms, zoned);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* A fixed span is milliseconds — except on a named zone, where a whole
|
|
109
|
+
* number of days is a calendar span: a day the clock changed on is 23
|
|
110
|
+
* or 25 hours long, and a ladder that called it 24 would drift a bucket
|
|
111
|
+
* boundary off local midnight for the rest of the year.
|
|
112
|
+
* @param {number} ms
|
|
113
|
+
* @param {boolean} zoned
|
|
114
|
+
* @returns {{ calendar: boolean, unit: string, amount: number, width: number }}
|
|
115
|
+
*/
|
|
116
|
+
function dayOrMillisecond(ms, zoned) {
|
|
117
|
+
if (zoned && ms % MS_PER_DAY === 0)
|
|
118
|
+
return { calendar: true, unit: 'day', amount: ms / MS_PER_DAY, width: 0 };
|
|
119
|
+
return { calendar: false, unit: 'millisecond', amount: ms, width: ms };
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** @param {number} value @param {string} role @returns {void} */
|
|
123
|
+
function requirePositiveInteger(value, role) {
|
|
124
|
+
if (!Number.isFinite(value) || !Number.isInteger(value) || value <= 0)
|
|
125
|
+
throw new TypeError(`${role} is a positive whole number, not ${value}`);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* A duration taken apart on a clock: which family it belongs to, how
|
|
130
|
+
* much of that family's unit it is, and the clock itself.
|
|
131
|
+
*
|
|
132
|
+
* The seam between the two kernels that need the same answer. A bucket
|
|
133
|
+
* ladder and a rolling window both have to know whether `P1M` is
|
|
134
|
+
* arithmetic or a calendar question, and whether `P1D` is 86,400,000
|
|
135
|
+
* milliseconds (it is, on UTC and on a fixed offset) or a day that might
|
|
136
|
+
* be 23 hours long (it is, on a named zone).
|
|
137
|
+
*
|
|
138
|
+
* @param {number | string} every
|
|
139
|
+
* @param {Object} [options] - the clock, as {@link resolveClock} takes it
|
|
140
|
+
* @param {string} [options.zone]
|
|
141
|
+
* @param {number} [options.offset]
|
|
142
|
+
* @param {import('./zone.js').ZoneProvider} [options.provider]
|
|
143
|
+
* @param {'reject' | 'earlier' | 'later'} [options.disambiguation]
|
|
144
|
+
* @returns {{ calendar: boolean, unit: string, amount: number, width: number, clock: Clock }}
|
|
145
|
+
* @throws {TypeError} for a span that is not one positive whole family,
|
|
146
|
+
* or a named zone with no provider
|
|
147
|
+
*/
|
|
148
|
+
export function compileSpan(every, options = {}) {
|
|
149
|
+
const clock = resolveClock(options);
|
|
150
|
+
const zoned = options.zone !== undefined && options.zone !== 'UTC';
|
|
151
|
+
return { ...requireEvery(every, zoned), clock };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* The bucket ladder for a specification, validated once and returned as
|
|
156
|
+
* the four boundary operations everything downstream needs.
|
|
157
|
+
*
|
|
158
|
+
* `spec` may be the width alone (`compileBuckets('PT15M')`) or a record
|
|
159
|
+
* with `every` and an optional `origin`. `options` carries the clock —
|
|
160
|
+
* nothing, `{ offset }`, or `{ zone, provider }` — and defaults to UTC.
|
|
161
|
+
*
|
|
162
|
+
* The default `origin` is local `1970-01-01T00:00:00` on that clock, so
|
|
163
|
+
* a daily bucket in `+02:00` falls on local midnight rather than on
|
|
164
|
+
* UTC's, and a monthly bucket falls on the first of the month.
|
|
165
|
+
*
|
|
166
|
+
* @param {number | string | { every: number | string, origin?: number | string }} spec
|
|
167
|
+
* @param {Object} [options] - the clock, as {@link resolveClock} takes it
|
|
168
|
+
* @param {string} [options.zone]
|
|
169
|
+
* @param {number} [options.offset]
|
|
170
|
+
* @param {import('./zone.js').ZoneProvider} [options.provider]
|
|
171
|
+
* @param {'reject' | 'earlier' | 'later'} [options.disambiguation]
|
|
172
|
+
* @returns {CompiledBuckets}
|
|
173
|
+
* @throws {TypeError} for a width that is not a positive whole span, a
|
|
174
|
+
* width mixing calendar and fixed units, an origin naming no instant,
|
|
175
|
+
* or a named zone with no provider
|
|
176
|
+
* @example
|
|
177
|
+
* const b = compileBuckets('PT15M');
|
|
178
|
+
* b.floor(Date.UTC(2026, 0, 1, 9, 7)); // 09:00
|
|
179
|
+
* b.startOf(b.indexOf(0) + 1); // the boundary after the epoch
|
|
180
|
+
*/
|
|
181
|
+
export function compileBuckets(spec, options = {}) {
|
|
182
|
+
const record = (typeof spec === 'number' || typeof spec === 'string') ? { every: spec } : spec;
|
|
183
|
+
if (record === null || typeof record !== 'object')
|
|
184
|
+
throw new TypeError('a bucket spec is a width, or a record with an \'every\'');
|
|
185
|
+
const shape = compileSpan(record.every, options);
|
|
186
|
+
const { clock } = shape;
|
|
187
|
+
const origin = record.origin === undefined
|
|
188
|
+
? clock.epochOf({ year: 1970, month: 1, day: 1, hours: 0, minutes: 0, seconds: 0 })
|
|
189
|
+
: toEpoch(record.origin);
|
|
190
|
+
return shape.calendar
|
|
191
|
+
? calendarLadder(record.every, shape, origin, clock)
|
|
192
|
+
: fixedLadder(record.every, shape, origin, clock.zone);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The integer ladder. `Math.floor` rather than a truncating division is
|
|
197
|
+
* the whole of it: before 1970 a truncated quotient rounds towards zero
|
|
198
|
+
* and puts an instant in the bucket after its own.
|
|
199
|
+
* @param {number | string} every
|
|
200
|
+
* @param {{ unit: string, amount: number, width: number }} shape
|
|
201
|
+
* @param {number} origin
|
|
202
|
+
* @param {string} zone
|
|
203
|
+
* @returns {CompiledBuckets}
|
|
204
|
+
*/
|
|
205
|
+
function fixedLadder(every, shape, origin, zone) {
|
|
206
|
+
const { width } = shape;
|
|
207
|
+
const startOf = (index) => origin + index * width;
|
|
208
|
+
const indexOf = (at) => Math.floor((at - origin) / width);
|
|
209
|
+
return {
|
|
210
|
+
every, calendar: false, unit: shape.unit, amount: shape.amount, width, zone, origin,
|
|
211
|
+
startOf, indexOf, floor: (at) => origin + Math.floor((at - origin) / width) * width,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The calendar ladder. A boundary is the anchor moved `index × amount`
|
|
217
|
+
* units by the calendar kernel and read back on the clock, so month
|
|
218
|
+
* lengths, leap days and — on a named zone — the days that are not 24
|
|
219
|
+
* hours long are all the provider's problem rather than a multiplier.
|
|
220
|
+
*
|
|
221
|
+
* `indexOf` estimates from the raw span and then corrects against the
|
|
222
|
+
* boundaries themselves. The estimate is never more than one position
|
|
223
|
+
* out (a month is 28-31 days; a day is 23-25 hours), so the correction
|
|
224
|
+
* is a couple of comparisons rather than a search — but it is a loop
|
|
225
|
+
* with a bound rather than an assumption, because the provider is the
|
|
226
|
+
* caller's code.
|
|
227
|
+
* @param {number | string} every
|
|
228
|
+
* @param {{ unit: string, amount: number }} shape
|
|
229
|
+
* @param {number} origin
|
|
230
|
+
* @param {Clock} clock
|
|
231
|
+
* @returns {CompiledBuckets}
|
|
232
|
+
*/
|
|
233
|
+
function calendarLadder(every, shape, origin, clock) {
|
|
234
|
+
const { unit, amount } = shape;
|
|
235
|
+
const anchor = clock.partsAt(origin);
|
|
236
|
+
/** One boundary, memoized: a resample walks the same two repeatedly. */
|
|
237
|
+
let lastIndex = 0;
|
|
238
|
+
let lastStart = origin;
|
|
239
|
+
const startOf = (index) => {
|
|
240
|
+
if (index === lastIndex)
|
|
241
|
+
return lastStart;
|
|
242
|
+
const at = index === 0 ? origin : clock.epochOf(addToParts(anchor, index * amount, unit));
|
|
243
|
+
lastIndex = index;
|
|
244
|
+
lastStart = at;
|
|
245
|
+
return at;
|
|
246
|
+
};
|
|
247
|
+
const estimate = unit === 'month'
|
|
248
|
+
? (/** @type {number} */ at) => {
|
|
249
|
+
const parts = clock.partsAt(at);
|
|
250
|
+
return Math.floor(((parts.year - anchor.year) * 12 + (parts.month - anchor.month)) / amount);
|
|
251
|
+
}
|
|
252
|
+
: (/** @type {number} */ at) => Math.floor(Math.floor((at - origin) / MS_PER_DAY) / amount);
|
|
253
|
+
const indexOf = (at) => {
|
|
254
|
+
let index = estimate(at);
|
|
255
|
+
for (let guard = 0; startOf(index) > at; guard++) {
|
|
256
|
+
if (guard > 4)
|
|
257
|
+
throw new RangeError(`the '${every}' ladder could not be walked back to ${at}`);
|
|
258
|
+
index--;
|
|
259
|
+
}
|
|
260
|
+
for (let guard = 0; startOf(index + 1) <= at; guard++) {
|
|
261
|
+
if (guard > 4)
|
|
262
|
+
throw new RangeError(`the '${every}' ladder could not be walked forward to ${at}`);
|
|
263
|
+
index++;
|
|
264
|
+
}
|
|
265
|
+
return index;
|
|
266
|
+
};
|
|
267
|
+
return {
|
|
268
|
+
every, calendar: true, unit, amount, width: 0, zone: clock.zone, origin,
|
|
269
|
+
startOf, indexOf, floor: (at) => startOf(indexOf(at)),
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
//#endregion
|
|
274
|
+
|
|
275
|
+
//#region resample
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* @param {any} name
|
|
279
|
+
* @param {readonly string[]} allowed
|
|
280
|
+
* @param {string} role
|
|
281
|
+
* @returns {string}
|
|
282
|
+
*/
|
|
283
|
+
function requireMember(name, allowed, role) {
|
|
284
|
+
if (typeof name !== 'string' || !allowed.includes(name))
|
|
285
|
+
throw new TypeError(`${role} is ${allowed.map((a) => `'${a}'`).join(', ')}, not ${JSON.stringify(name)}`);
|
|
286
|
+
return name;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
/**
|
|
290
|
+
* `resampleSeries`' closed specification: the D5 bucket contract, the
|
|
291
|
+
* clock it reads and where a row keeps its instant and its reading.
|
|
292
|
+
*/
|
|
293
|
+
export const RESAMPLE_MEMBERS = Object.freeze([
|
|
294
|
+
'every', 'origin', 'start', 'end', 'aggregate', 'fill', 'at', 'value',
|
|
295
|
+
...CLOCK_MEMBERS,
|
|
296
|
+
]);
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Bucket a series, reduce each bucket to one number, and say what the
|
|
300
|
+
* empty ones mean.
|
|
301
|
+
*
|
|
302
|
+
* The result is ascending `{ at, value, count }` records labelled at
|
|
303
|
+
* their bucket's **start**, which is the only label that is a boundary
|
|
304
|
+
* rather than a summary of where the rows happened to land.
|
|
305
|
+
*
|
|
306
|
+
* `count` is the number of **source rows** the bucket held — duplicates
|
|
307
|
+
* and measured gaps included — so it is the honest denominator of what
|
|
308
|
+
* was seen, not of what could be added up. The six value aggregates
|
|
309
|
+
* (`sum`, `mean`, `min`, `max`, `first`, `last`) all skip `null`
|
|
310
|
+
* readings, so `value` is `null` exactly when the bucket had nothing to
|
|
311
|
+
* measure, and `count` tells you whether that was because nobody
|
|
312
|
+
* reported or because everybody reported a gap. `aggregate: 'count'`
|
|
313
|
+
* returns that same row count as the value.
|
|
314
|
+
*
|
|
315
|
+
* The window, when `start`/`end` are not given, is the data's own: the
|
|
316
|
+
* bucket holding the first sample through the bucket holding the last.
|
|
317
|
+
* Pass them when an empty edge matters — an empty Monday is only a
|
|
318
|
+
* missing Monday once the caller says the week starts then.
|
|
319
|
+
*
|
|
320
|
+
* The five fill policies decide what an **empty** bucket says, and
|
|
321
|
+
* nothing else — a bucket that held rows and no numbers reports `null`
|
|
322
|
+
* because that is a measurement:
|
|
323
|
+
*
|
|
324
|
+
* | fill | an empty bucket |
|
|
325
|
+
* |---|---|
|
|
326
|
+
* | `omit` | is not emitted (the default: a gap is not a row) |
|
|
327
|
+
* | `null` | is emitted as `null` |
|
|
328
|
+
* | `zero` | is emitted as `0` |
|
|
329
|
+
* | `locf` | repeats the last value before it |
|
|
330
|
+
* | `linear` | is interpolated between its two neighbours |
|
|
331
|
+
*
|
|
332
|
+
* Neither `locf` nor `linear` invents a value at the leading edge, and
|
|
333
|
+
* `linear` needs a value on **both** sides: with no anchor to carry or
|
|
334
|
+
* to interpolate from, the bucket stays `null`. To seed one, widen the
|
|
335
|
+
* window until the earlier reading falls inside it — the seed is then a
|
|
336
|
+
* bucket with data, which is the only kind of anchor this function will
|
|
337
|
+
* extrapolate from. (With `aggregate: 'count'` an empty bucket is `0` by
|
|
338
|
+
* definition, so fill only decides whether it appears at all.)
|
|
339
|
+
*
|
|
340
|
+
* @param {any[]} rows - the samples, in any order
|
|
341
|
+
* @param {Object} spec
|
|
342
|
+
* @param {number | string} spec.every - the bucket width
|
|
343
|
+
* @param {number | string} [spec.origin] - where a boundary falls
|
|
344
|
+
* (default: local `1970-01-01T00:00:00` on the clock)
|
|
345
|
+
* @param {number | string} [spec.start] - the half-open window's start
|
|
346
|
+
* @param {number | string} [spec.end] - the half-open window's end
|
|
347
|
+
* @param {'sum'|'mean'|'min'|'max'|'first'|'last'|'count'} [spec.aggregate]
|
|
348
|
+
* default `'mean'`
|
|
349
|
+
* @param {'omit'|'null'|'zero'|'locf'|'linear'} [spec.fill] default `'omit'`
|
|
350
|
+
* @param {string} [spec.zone] - a named zone, needing `provider`
|
|
351
|
+
* @param {number} [spec.offset] - minutes east of UTC
|
|
352
|
+
* @param {import('./zone.js').ZoneProvider} [spec.provider]
|
|
353
|
+
* @param {'reject'|'earlier'|'later'} [spec.disambiguation]
|
|
354
|
+
* @param {string | ((item: any, index: number) => any)} [spec.at] - where
|
|
355
|
+
* the instant lives in a source row (default `'at'`)
|
|
356
|
+
* @param {string | ((item: any, index: number) => any)} [spec.value]
|
|
357
|
+
* where the reading lives (default `'value'`)
|
|
358
|
+
* @returns {{ at: number, value: number | null, count: number }[]}
|
|
359
|
+
* @throws {TypeError} for a bad width, origin, window, aggregate or fill,
|
|
360
|
+
* or a row that is not a canonical sample
|
|
361
|
+
* @example
|
|
362
|
+
* resampleSeries(readings, { every: 'PT1H', aggregate: 'mean', fill: 'linear' });
|
|
363
|
+
* resampleSeries(readings, { every: 'P1M', zone: 'Europe/Amsterdam', provider });
|
|
364
|
+
*/
|
|
365
|
+
export function resampleSeries(rows, spec) {
|
|
366
|
+
requireSpecMembers(spec, RESAMPLE_MEMBERS, 'resampleSeries',
|
|
367
|
+
'a resample spec is an object with an \'every\'');
|
|
368
|
+
const samples = canonicalSeries(rows, spec);
|
|
369
|
+
const buckets = compileBuckets(spec, spec);
|
|
370
|
+
const aggregate = requireMember(spec.aggregate ?? 'mean', AGGREGATES, 'aggregate');
|
|
371
|
+
const fill = requireMember(spec.fill ?? 'omit', FILLS, 'fill');
|
|
372
|
+
|
|
373
|
+
const lo = spec.start === undefined ? null : toEpoch(spec.start);
|
|
374
|
+
const hi = spec.end === undefined ? null : toEpoch(spec.end);
|
|
375
|
+
if (lo !== null && hi !== null && !(lo < hi))
|
|
376
|
+
throw new TypeError(`the window ends at ${hi}, at or before its start ${lo}`);
|
|
377
|
+
|
|
378
|
+
const from = lo === null ? 0 : lowerBoundTime(samples, lo);
|
|
379
|
+
const to = hi === null ? samples.length : lowerBoundTime(samples, hi);
|
|
380
|
+
// with no window of its own and no data, there is nothing to enumerate
|
|
381
|
+
// between — and "now" is not an answer this module is allowed to give
|
|
382
|
+
if (from >= to && (lo === null || hi === null))
|
|
383
|
+
return [];
|
|
384
|
+
|
|
385
|
+
const firstAt = lo === null ? samples[from].at : lo;
|
|
386
|
+
// the last instant a bucket may still be emitted for: the explicit end
|
|
387
|
+
// is exclusive, the derived one is the final sample and is inside
|
|
388
|
+
const limit = hi === null ? samples[to - 1].at + 1 : hi;
|
|
389
|
+
|
|
390
|
+
/** @type {{ at: number, value: number | null, count: number }[]} */
|
|
391
|
+
const out = [];
|
|
392
|
+
/** Which emitted rows had no source rows at all — the fill's business. */
|
|
393
|
+
const empties = [];
|
|
394
|
+
let index = buckets.indexOf(firstAt);
|
|
395
|
+
let start = buckets.startOf(index);
|
|
396
|
+
let i = from;
|
|
397
|
+
while (start < limit) {
|
|
398
|
+
const end = buckets.calendar ? buckets.startOf(index + 1) : start + buckets.width;
|
|
399
|
+
let count = 0;
|
|
400
|
+
let n = 0;
|
|
401
|
+
let sum = 0;
|
|
402
|
+
let min = Infinity;
|
|
403
|
+
let max = -Infinity;
|
|
404
|
+
let first = null;
|
|
405
|
+
let last = null;
|
|
406
|
+
while (i < to && samples[i].at < end) {
|
|
407
|
+
const value = samples[i].value;
|
|
408
|
+
count++;
|
|
409
|
+
if (value !== null) {
|
|
410
|
+
n++;
|
|
411
|
+
sum += value;
|
|
412
|
+
if (value < min) min = value;
|
|
413
|
+
if (value > max) max = value;
|
|
414
|
+
if (first === null) first = value;
|
|
415
|
+
last = value;
|
|
416
|
+
}
|
|
417
|
+
i++;
|
|
418
|
+
}
|
|
419
|
+
if (count === 0) {
|
|
420
|
+
if (fill !== 'omit') {
|
|
421
|
+
empties.push(out.length);
|
|
422
|
+
out.push({ at: start, value: aggregate === 'count' ? 0 : emptyValue(fill), count: 0 });
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
else {
|
|
426
|
+
out.push({ at: start, count, value: reduce(aggregate, count, n, sum, min, max, first, last) });
|
|
427
|
+
}
|
|
428
|
+
index++;
|
|
429
|
+
start = end;
|
|
430
|
+
}
|
|
431
|
+
if (empties.length !== 0 && aggregate !== 'count') {
|
|
432
|
+
if (fill === 'locf') carryForward(out, empties);
|
|
433
|
+
else if (fill === 'linear') interpolate(out, empties);
|
|
434
|
+
}
|
|
435
|
+
return out;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
/** @param {string} fill @returns {number | null} */
|
|
439
|
+
function emptyValue(fill) {
|
|
440
|
+
return fill === 'zero' ? 0 : null;
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
/**
|
|
444
|
+
* One bucket's rows, reduced. `null` is the answer whenever nothing in
|
|
445
|
+
* the bucket carried a number, which the six value aggregates agree on.
|
|
446
|
+
* @param {string} aggregate
|
|
447
|
+
* @param {number} count - source rows, gaps included
|
|
448
|
+
* @param {number} n - rows carrying a number
|
|
449
|
+
* @param {number} sum
|
|
450
|
+
* @param {number} min
|
|
451
|
+
* @param {number} max
|
|
452
|
+
* @param {number | null} first
|
|
453
|
+
* @param {number | null} last
|
|
454
|
+
* @returns {number | null}
|
|
455
|
+
*/
|
|
456
|
+
function reduce(aggregate, count, n, sum, min, max, first, last) {
|
|
457
|
+
if (aggregate === 'count')
|
|
458
|
+
return count;
|
|
459
|
+
if (n === 0)
|
|
460
|
+
return null;
|
|
461
|
+
switch (aggregate) {
|
|
462
|
+
case 'sum': return sum;
|
|
463
|
+
case 'mean': return sum / n;
|
|
464
|
+
case 'min': return min;
|
|
465
|
+
case 'max': return max;
|
|
466
|
+
case 'first': return first;
|
|
467
|
+
default: return last;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
/**
|
|
472
|
+
* `locf` — the last value observed before the gap, repeated across it.
|
|
473
|
+
* A gap before the first value stays `null`: carrying a value backwards
|
|
474
|
+
* is inventing one, and this module does not.
|
|
475
|
+
* @param {{ at: number, value: number | null, count: number }[]} out
|
|
476
|
+
* @param {number[]} empties
|
|
477
|
+
* @returns {void}
|
|
478
|
+
*/
|
|
479
|
+
function carryForward(out, empties) {
|
|
480
|
+
let carried = null;
|
|
481
|
+
let next = 0;
|
|
482
|
+
for (let i = 0; i < out.length; i++) {
|
|
483
|
+
if (next < empties.length && empties[next] === i) {
|
|
484
|
+
out[i].value = carried;
|
|
485
|
+
next++;
|
|
486
|
+
}
|
|
487
|
+
else if (out[i].value !== null)
|
|
488
|
+
carried = out[i].value;
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/**
|
|
493
|
+
* `linear` — a straight line between the values on either side of the
|
|
494
|
+
* gap, read at each empty bucket's own start. A run with no value on
|
|
495
|
+
* one side has no line to be on and stays `null`.
|
|
496
|
+
* @param {{ at: number, value: number | null, count: number }[]} out
|
|
497
|
+
* @param {number[]} empties
|
|
498
|
+
* @returns {void}
|
|
499
|
+
*/
|
|
500
|
+
function interpolate(out, empties) {
|
|
501
|
+
const isEmpty = new Uint8Array(out.length);
|
|
502
|
+
for (const i of empties) isEmpty[i] = 1;
|
|
503
|
+
for (let i = 0; i < empties.length;) {
|
|
504
|
+
// the maximal run of empty buckets this one starts
|
|
505
|
+
let last = empties[i];
|
|
506
|
+
let j = i + 1;
|
|
507
|
+
while (j < empties.length && empties[j] === last + 1) {
|
|
508
|
+
last = empties[j];
|
|
509
|
+
j++;
|
|
510
|
+
}
|
|
511
|
+
const before = anchor(out, isEmpty, empties[i] - 1, -1);
|
|
512
|
+
const after = anchor(out, isEmpty, last + 1, 1);
|
|
513
|
+
if (before >= 0 && after < out.length) {
|
|
514
|
+
const a = out[before];
|
|
515
|
+
const b = out[after];
|
|
516
|
+
const slope = (/** @type {number} */(b.value) - /** @type {number} */(a.value)) / (b.at - a.at);
|
|
517
|
+
for (let k = empties[i]; k <= last; k++)
|
|
518
|
+
out[k].value = /** @type {number} */(a.value) + slope * (out[k].at - a.at);
|
|
519
|
+
}
|
|
520
|
+
i = j;
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* The nearest bucket in one direction that actually measured something.
|
|
526
|
+
* A bucket that held rows and no numbers is not an anchor: it is the
|
|
527
|
+
* report that there was nothing to measure.
|
|
528
|
+
* @param {{ value: number | null }[]} out
|
|
529
|
+
* @param {Uint8Array} isEmpty
|
|
530
|
+
* @param {number} from
|
|
531
|
+
* @param {number} step
|
|
532
|
+
* @returns {number} an index, or one past the end of the search
|
|
533
|
+
*/
|
|
534
|
+
function anchor(out, isEmpty, from, step) {
|
|
535
|
+
for (let i = from; i >= 0 && i < out.length; i += step) {
|
|
536
|
+
if (isEmpty[i] === 0 && out[i].value !== null)
|
|
537
|
+
return i;
|
|
538
|
+
}
|
|
539
|
+
return step < 0 ? -1 : out.length;
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
//#endregion
|