pond-ts 0.25.0 → 0.27.0
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/CHANGELOG.md +39 -1
- package/dist/batch/by-column.d.ts +29 -0
- package/dist/batch/by-column.js +166 -0
- package/dist/batch/operators/column-builders.d.ts +31 -0
- package/dist/batch/operators/column-builders.js +103 -0
- package/dist/batch/time-series.d.ts +40 -0
- package/dist/batch/time-series.js +99 -62
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
7
7
|
file covers both packages. Pre-1.0: minor bumps may include new features and
|
|
8
8
|
type-level changes; patch bumps are strictly additive.
|
|
9
9
|
|
|
10
|
-
[Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.
|
|
10
|
+
[Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...HEAD
|
|
11
|
+
[0.27.0]: https://github.com/pjm17971/pond-ts/compare/v0.26.0...v0.27.0
|
|
12
|
+
[0.26.0]: https://github.com/pjm17971/pond-ts/compare/v0.25.0...v0.26.0
|
|
11
13
|
[0.25.0]: https://github.com/pjm17971/pond-ts/compare/v0.24.0...v0.25.0
|
|
12
14
|
[0.24.0]: https://github.com/pjm17971/pond-ts/compare/v0.23.0...v0.24.0
|
|
13
15
|
[0.23.0]: https://github.com/pjm17971/pond-ts/compare/v0.22.0...v0.23.0
|
|
@@ -19,6 +21,42 @@ type-level changes; patch bumps are strictly additive.
|
|
|
19
21
|
|
|
20
22
|
## [Unreleased]
|
|
21
23
|
|
|
24
|
+
## [0.27.0] — 2026-06-16
|
|
25
|
+
|
|
26
|
+
### Added
|
|
27
|
+
|
|
28
|
+
- **`TimeSeries.byColumn(col, { width, origin? } | { edges }, mapping)` —
|
|
29
|
+
value-axis aggregation.** Where `aggregate` buckets the temporal key,
|
|
30
|
+
`byColumn` buckets rows by the **value** of a numeric column and reduces each
|
|
31
|
+
bin, returning an ordered array of `{ start, end, ...aggregates }` records
|
|
32
|
+
(one per bin) — not a `TimeSeries`, since value-bins (distance / power ranges)
|
|
33
|
+
aren't time-indexed. `{ width }` gives even bins emitted contiguously from the
|
|
34
|
+
lowest to highest occupied bin (monotonic source → splits / profile;
|
|
35
|
+
non-monotonic → histogram); `{ edges }` gives explicit ascending bins (e.g.
|
|
36
|
+
power zones). Reuses the reducer mapping + non-finite policy. Rows whose bin
|
|
37
|
+
value is missing / non-finite (or, for `edges`, out of range) are dropped;
|
|
38
|
+
empty bins emit the reducer's empty value; a non-finite / wrong-kind reducer
|
|
39
|
+
result throws `ValidationError`. See `docs/notes/bycolumn-value-axis.md`.
|
|
40
|
+
|
|
41
|
+
### Changed
|
|
42
|
+
|
|
43
|
+
- **`rolling(...)` now builds its output columns directly instead of
|
|
44
|
+
materializing events.** The rolling family was the last batch operator still
|
|
45
|
+
assembling a row per event and re-validating/re-packing it through the
|
|
46
|
+
constructor; it now reads the key axis and source values straight off the
|
|
47
|
+
columnar store and writes the result columns via trusted construction. The
|
|
48
|
+
result is unchanged for the common (scalar) cases. Measured: `rolling` with
|
|
49
|
+
`avg`/`sum` ~2.2–2.7× faster; rolling `stdev` on 100k events ~3.3–7.3×
|
|
50
|
+
(a 1-event window 45.7 ms → 6.3 ms); partitioned rolling ~1.8×.
|
|
51
|
+
`baseline` / `outliers` (which delegate to `rolling`) inherit the speedup.
|
|
52
|
+
- **Behavior note — `array` columns:** an identity-comparing reducer (`keep`,
|
|
53
|
+
or a custom reducer using `===` on the cell) on an `array`-kind source
|
|
54
|
+
column now compares the value stored in the column, not the original object
|
|
55
|
+
reference passed at construction. Two rows given the *same* array object
|
|
56
|
+
therefore read as distinct. Scalar columns (number / string / boolean) are
|
|
57
|
+
unaffected. A non-finite or wrong-kind reducer result is still rejected with
|
|
58
|
+
a `ValidationError`, exactly as the constructor's intake did.
|
|
59
|
+
|
|
22
60
|
## [0.25.0] — 2026-06-15
|
|
23
61
|
|
|
24
62
|
### Changed
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import type { ColumnSchema, ColumnarStore } from '../columnar/index.js';
|
|
2
|
+
import type { ColumnValue } from '../schema/index.js';
|
|
3
|
+
import type { AggregateColumnSpec } from './aggregate-columns.js';
|
|
4
|
+
/**
|
|
5
|
+
* Binning for {@link TimeSeries.byColumn}: even-`width` bins (optionally shifted
|
|
6
|
+
* by `origin`, default 0) or explicit ascending `edges`. See
|
|
7
|
+
* `docs/notes/bycolumn-value-axis.md`.
|
|
8
|
+
*/
|
|
9
|
+
export type BinSpec = {
|
|
10
|
+
width: number;
|
|
11
|
+
origin?: number;
|
|
12
|
+
} | {
|
|
13
|
+
edges: readonly number[];
|
|
14
|
+
};
|
|
15
|
+
/** One value-bin's record: its `[start, end)` range plus the mapped aggregates. */
|
|
16
|
+
export type BinRecord = {
|
|
17
|
+
start: number;
|
|
18
|
+
end: number;
|
|
19
|
+
} & Record<string, ColumnValue | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* Value-axis aggregation runtime. Buckets the store's rows by the value of
|
|
22
|
+
* `binColName` and reduces each bin via `columnSpecs`, returning one
|
|
23
|
+
* {@link BinRecord} per bin in ascending order. Reads straight off the columnar
|
|
24
|
+
* store (no event materialization); rows whose bin value is missing / non-finite
|
|
25
|
+
* (or, for `edges`, out of range) contribute to no bin. Empty bins emit each
|
|
26
|
+
* reducer's empty value, like an empty `aggregate` bucket.
|
|
27
|
+
*/
|
|
28
|
+
export declare function computeByColumn(store: ColumnarStore<ColumnSchema>, binColName: string, spec: BinSpec, columnSpecs: ReadonlyArray<AggregateColumnSpec>): BinRecord[];
|
|
29
|
+
//# sourceMappingURL=by-column.d.ts.map
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { bucketStateFor } from '../reducers/index.js';
|
|
2
|
+
// Guards a `width` spec from an accidental explosion (e.g. a sub-unit width over
|
|
3
|
+
// a huge range) that would allocate millions of empty output records.
|
|
4
|
+
const MAX_WIDTH_BINS = 1_000_000;
|
|
5
|
+
/**
|
|
6
|
+
* Value-axis aggregation runtime. Buckets the store's rows by the value of
|
|
7
|
+
* `binColName` and reduces each bin via `columnSpecs`, returning one
|
|
8
|
+
* {@link BinRecord} per bin in ascending order. Reads straight off the columnar
|
|
9
|
+
* store (no event materialization); rows whose bin value is missing / non-finite
|
|
10
|
+
* (or, for `edges`, out of range) contribute to no bin. Empty bins emit each
|
|
11
|
+
* reducer's empty value, like an empty `aggregate` bucket.
|
|
12
|
+
*/
|
|
13
|
+
export function computeByColumn(store, binColName, spec, columnSpecs) {
|
|
14
|
+
const binCol = store.columns.get(binColName);
|
|
15
|
+
if (binCol === undefined) {
|
|
16
|
+
throw new RangeError(`byColumn: unknown column '${binColName}'`);
|
|
17
|
+
}
|
|
18
|
+
if (binCol.kind !== 'number') {
|
|
19
|
+
throw new TypeError(`byColumn: column '${binColName}' must be a number column (got '${binCol.kind}')`);
|
|
20
|
+
}
|
|
21
|
+
// `start` / `end` carry the bin range in every record, so a mapping output
|
|
22
|
+
// can't claim them (it would silently overwrite the range).
|
|
23
|
+
for (const s of columnSpecs) {
|
|
24
|
+
if (s.output === 'start' || s.output === 'end') {
|
|
25
|
+
throw new RangeError(`byColumn: output name '${s.output}' is reserved for the bin range`);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
const sourceCols = columnSpecs.map((s) => store.columns.get(s.source));
|
|
29
|
+
const n = store.length;
|
|
30
|
+
// Resolve the bin assignment + the [start, end) of a given bin index.
|
|
31
|
+
const usesEdges = 'edges' in spec;
|
|
32
|
+
let binOf;
|
|
33
|
+
let rangeOf;
|
|
34
|
+
if (usesEdges) {
|
|
35
|
+
const edges = spec.edges;
|
|
36
|
+
if (edges.length < 2) {
|
|
37
|
+
throw new RangeError('byColumn: edges must have at least 2 entries');
|
|
38
|
+
}
|
|
39
|
+
for (let i = 0; i < edges.length; i += 1) {
|
|
40
|
+
if (!Number.isFinite(edges[i])) {
|
|
41
|
+
throw new RangeError(`byColumn: edges[${i}] is not finite`);
|
|
42
|
+
}
|
|
43
|
+
if (i > 0 && edges[i] <= edges[i - 1]) {
|
|
44
|
+
throw new RangeError('byColumn: edges must be strictly ascending');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
const last = edges.length - 1;
|
|
48
|
+
binOf = (v) => {
|
|
49
|
+
if (v < edges[0] || v >= edges[last])
|
|
50
|
+
return NaN; // out of range → drop
|
|
51
|
+
// rightmost edge <= v, clamped to a valid bin [0, last)
|
|
52
|
+
let lo = 0;
|
|
53
|
+
let hi = last; // bins are [0, last)
|
|
54
|
+
while (lo < hi) {
|
|
55
|
+
const mid = (lo + hi + 1) >>> 1;
|
|
56
|
+
if (edges[mid] <= v)
|
|
57
|
+
lo = mid;
|
|
58
|
+
else
|
|
59
|
+
hi = mid - 1;
|
|
60
|
+
}
|
|
61
|
+
return lo;
|
|
62
|
+
};
|
|
63
|
+
rangeOf = (i) => ({ start: edges[i], end: edges[i + 1] });
|
|
64
|
+
}
|
|
65
|
+
else {
|
|
66
|
+
const width = spec.width;
|
|
67
|
+
const origin = spec.origin ?? 0;
|
|
68
|
+
if (!Number.isFinite(width) || width <= 0) {
|
|
69
|
+
throw new RangeError('byColumn: width must be a positive finite number');
|
|
70
|
+
}
|
|
71
|
+
if (!Number.isFinite(origin)) {
|
|
72
|
+
throw new RangeError('byColumn: origin must be finite');
|
|
73
|
+
}
|
|
74
|
+
binOf = (v) => {
|
|
75
|
+
let bin = Math.floor((v - origin) / width);
|
|
76
|
+
// `floor((v−origin)/width)` (division) and the emitted boundary
|
|
77
|
+
// `origin + i*width` (multiplication) round independently, so for
|
|
78
|
+
// fractional widths a value can land just outside its floored bin's
|
|
79
|
+
// `[start, end)` (e.g. width 0.1, v = −3*0.1 → bin −4 whose end *is* v).
|
|
80
|
+
// Nudge the bin so `v ∈ [origin + bin*width, origin + (bin+1)*width)` —
|
|
81
|
+
// a ≤1-step correction for normal inputs. The counter caps the loop so a
|
|
82
|
+
// collapsed range (origin/width beyond float precision) can't spin; emit's
|
|
83
|
+
// representability check then rejects that bin.
|
|
84
|
+
let guard = 0;
|
|
85
|
+
while (v < origin + bin * width && guard++ < 4)
|
|
86
|
+
bin -= 1;
|
|
87
|
+
while (v >= origin + (bin + 1) * width && guard++ < 4)
|
|
88
|
+
bin += 1;
|
|
89
|
+
return bin;
|
|
90
|
+
};
|
|
91
|
+
rangeOf = (i) => ({
|
|
92
|
+
start: origin + i * width,
|
|
93
|
+
end: origin + (i + 1) * width,
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
// Scatter: one bucket-state set per occupied bin index.
|
|
97
|
+
const states = new Map();
|
|
98
|
+
let minBin = Infinity;
|
|
99
|
+
let maxBin = -Infinity;
|
|
100
|
+
for (let i = 0; i < n; i += 1) {
|
|
101
|
+
const bv = binCol.read(i);
|
|
102
|
+
if (typeof bv !== 'number' || !Number.isFinite(bv))
|
|
103
|
+
continue;
|
|
104
|
+
const bin = binOf(bv);
|
|
105
|
+
if (Number.isNaN(bin))
|
|
106
|
+
continue; // edges out-of-range (width bins may be negative)
|
|
107
|
+
let cells = states.get(bin);
|
|
108
|
+
if (cells === undefined) {
|
|
109
|
+
cells = columnSpecs.map((s) => bucketStateFor(s.reducer));
|
|
110
|
+
states.set(bin, cells);
|
|
111
|
+
}
|
|
112
|
+
for (let c = 0; c < columnSpecs.length; c += 1) {
|
|
113
|
+
cells[c].add(sourceCols[c].read(i));
|
|
114
|
+
}
|
|
115
|
+
if (bin < minBin)
|
|
116
|
+
minBin = bin;
|
|
117
|
+
if (bin > maxBin)
|
|
118
|
+
maxBin = bin;
|
|
119
|
+
}
|
|
120
|
+
const out = [];
|
|
121
|
+
const emit = (binIndex) => {
|
|
122
|
+
const { start, end } = rangeOf(binIndex);
|
|
123
|
+
// Every emitted bin must be a representable half-open `[start, end)`. A safe
|
|
124
|
+
// bin INDEX doesn't guarantee representable BOUNDARIES: at extreme
|
|
125
|
+
// magnitudes `origin + i*width` can collapse (`start === end`, e.g.
|
|
126
|
+
// origin 1e20 + width 1) or overflow (`end === Infinity`, e.g. width 1e308).
|
|
127
|
+
// (Edges are pre-validated finite + strictly ascending, so this only ever
|
|
128
|
+
// fires on a pathological width/origin.)
|
|
129
|
+
if (!Number.isFinite(start) || !Number.isFinite(end) || end <= start) {
|
|
130
|
+
throw new RangeError(`byColumn: bin [${start}, ${end}) is not a representable range — the origin/width magnitude exceeds float precision; use a larger width or explicit edges`);
|
|
131
|
+
}
|
|
132
|
+
const cells = states.get(binIndex);
|
|
133
|
+
const rec = { start, end };
|
|
134
|
+
for (let c = 0; c < columnSpecs.length; c += 1) {
|
|
135
|
+
// Occupied bin → that bin's own accumulated snapshot. Empty bin → a FRESH
|
|
136
|
+
// empty snapshot per bin (not a cached/shared value): array-kind reducers
|
|
137
|
+
// would otherwise alias one `[]` across bins, and a custom reducer's empty
|
|
138
|
+
// is `fn([])` which `aggregate` evaluates per empty bucket — match that.
|
|
139
|
+
rec[columnSpecs[c].output] = cells
|
|
140
|
+
? cells[c].snapshot()
|
|
141
|
+
: bucketStateFor(columnSpecs[c].reducer).snapshot();
|
|
142
|
+
}
|
|
143
|
+
out.push(rec);
|
|
144
|
+
};
|
|
145
|
+
if (usesEdges) {
|
|
146
|
+
const binCount = spec.edges.length - 1;
|
|
147
|
+
for (let b = 0; b < binCount; b += 1)
|
|
148
|
+
emit(b);
|
|
149
|
+
}
|
|
150
|
+
else if (states.size > 0) {
|
|
151
|
+
// A finite-but-huge value can floor to a bin index past the safe-integer
|
|
152
|
+
// range (or to ±Infinity on overflow); the emit loop's `b += 1` would then
|
|
153
|
+
// never advance. Reject it rather than spin.
|
|
154
|
+
if (!Number.isSafeInteger(minBin) || !Number.isSafeInteger(maxBin)) {
|
|
155
|
+
throw new RangeError('byColumn: the data range and width produce a bin index outside the safe integer range; use a larger width');
|
|
156
|
+
}
|
|
157
|
+
const binCount = maxBin - minBin + 1;
|
|
158
|
+
if (binCount > MAX_WIDTH_BINS) {
|
|
159
|
+
throw new RangeError(`byColumn: width produces ${binCount} bins (> ${MAX_WIDTH_BINS}); use a larger width or explicit edges`);
|
|
160
|
+
}
|
|
161
|
+
for (let b = minBin; b <= maxBin; b += 1)
|
|
162
|
+
emit(b);
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
//# sourceMappingURL=by-column.js.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { type Column } from '../../columnar/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Build a typed {@link Column} from a per-row value array, dispatching on the
|
|
4
|
+
* column's `kind`. An `undefined` cell becomes missing (its validity bit is
|
|
5
|
+
* left unset, so `read(i)` returns `undefined`). Numeric arrays reject
|
|
6
|
+
* non-finite values at construction (`float64ColumnFromArray`) — packed
|
|
7
|
+
* numeric columns stay NaN-free.
|
|
8
|
+
*
|
|
9
|
+
* This is the shared form of the kind→builder dispatch that `fill`
|
|
10
|
+
* (`buildFilledColumn`), `map` (its inline builder), and `collapse`
|
|
11
|
+
* (`buildScalarColumn`) each carry a local copy of — flagged there as a
|
|
12
|
+
* follow-up to converge. Columnar `rolling` is the fourth caller and uses
|
|
13
|
+
* this one directly; retrofitting the other three onto it is a separate
|
|
14
|
+
* (optional) cleanup.
|
|
15
|
+
*/
|
|
16
|
+
export declare function columnFromValuesByKind(kind: string, values: unknown[]): Column;
|
|
17
|
+
/**
|
|
18
|
+
* Throw if any *defined* value fails the column's `kind` contract, mirroring
|
|
19
|
+
* the constructor's strict intake (`validate.ts`): a `number` must be finite;
|
|
20
|
+
* `string` / `boolean` must match `typeof`; an `array` must be a real array
|
|
21
|
+
* whose elements are each a finite number, string, or boolean. `undefined`
|
|
22
|
+
* (missing) values are allowed.
|
|
23
|
+
*
|
|
24
|
+
* Computed-writer paths that assemble via trusted construction bypass intake,
|
|
25
|
+
* and {@link columnFromValuesByKind}'s `*FromArray` builders silently coerce a
|
|
26
|
+
* kind mismatch to a *missing* cell — so a caller that must preserve the intake
|
|
27
|
+
* rejection (e.g. columnar `rolling`, matching `mapColumns`) calls this first.
|
|
28
|
+
* `label` is prefixed to the thrown message.
|
|
29
|
+
*/
|
|
30
|
+
export declare function assertColumnValuesMatchKind(kind: string, values: ReadonlyArray<unknown>, label: string): void;
|
|
31
|
+
//# sourceMappingURL=column-builders.d.ts.map
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { arrayColumnFromArray, booleanColumnFromArray, float64ColumnFromArray, stringColumnFromArray, } from '../../columnar/index.js';
|
|
2
|
+
import { ValidationError } from '../../core/errors.js';
|
|
3
|
+
/**
|
|
4
|
+
* Build a typed {@link Column} from a per-row value array, dispatching on the
|
|
5
|
+
* column's `kind`. An `undefined` cell becomes missing (its validity bit is
|
|
6
|
+
* left unset, so `read(i)` returns `undefined`). Numeric arrays reject
|
|
7
|
+
* non-finite values at construction (`float64ColumnFromArray`) — packed
|
|
8
|
+
* numeric columns stay NaN-free.
|
|
9
|
+
*
|
|
10
|
+
* This is the shared form of the kind→builder dispatch that `fill`
|
|
11
|
+
* (`buildFilledColumn`), `map` (its inline builder), and `collapse`
|
|
12
|
+
* (`buildScalarColumn`) each carry a local copy of — flagged there as a
|
|
13
|
+
* follow-up to converge. Columnar `rolling` is the fourth caller and uses
|
|
14
|
+
* this one directly; retrofitting the other three onto it is a separate
|
|
15
|
+
* (optional) cleanup.
|
|
16
|
+
*/
|
|
17
|
+
export function columnFromValuesByKind(kind, values) {
|
|
18
|
+
switch (kind) {
|
|
19
|
+
case 'number':
|
|
20
|
+
return float64ColumnFromArray(values);
|
|
21
|
+
case 'string':
|
|
22
|
+
return stringColumnFromArray(values);
|
|
23
|
+
case 'boolean':
|
|
24
|
+
return booleanColumnFromArray(values);
|
|
25
|
+
case 'array':
|
|
26
|
+
// ArrayValue cells; the cast is the kind dispatch's trust point.
|
|
27
|
+
return arrayColumnFromArray(values);
|
|
28
|
+
default:
|
|
29
|
+
throw new TypeError(`columnFromValuesByKind: unsupported kind '${kind}'`);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Throw if any *defined* value fails the column's `kind` contract, mirroring
|
|
34
|
+
* the constructor's strict intake (`validate.ts`): a `number` must be finite;
|
|
35
|
+
* `string` / `boolean` must match `typeof`; an `array` must be a real array
|
|
36
|
+
* whose elements are each a finite number, string, or boolean. `undefined`
|
|
37
|
+
* (missing) values are allowed.
|
|
38
|
+
*
|
|
39
|
+
* Computed-writer paths that assemble via trusted construction bypass intake,
|
|
40
|
+
* and {@link columnFromValuesByKind}'s `*FromArray` builders silently coerce a
|
|
41
|
+
* kind mismatch to a *missing* cell — so a caller that must preserve the intake
|
|
42
|
+
* rejection (e.g. columnar `rolling`, matching `mapColumns`) calls this first.
|
|
43
|
+
* `label` is prefixed to the thrown message.
|
|
44
|
+
*/
|
|
45
|
+
export function assertColumnValuesMatchKind(kind, values, label) {
|
|
46
|
+
for (let i = 0; i < values.length; i += 1) {
|
|
47
|
+
const v = values[i];
|
|
48
|
+
if (v === undefined)
|
|
49
|
+
continue; // missing cell — allowed
|
|
50
|
+
let ok;
|
|
51
|
+
switch (kind) {
|
|
52
|
+
case 'number':
|
|
53
|
+
ok = typeof v === 'number' && Number.isFinite(v);
|
|
54
|
+
break;
|
|
55
|
+
case 'string':
|
|
56
|
+
ok = typeof v === 'string';
|
|
57
|
+
break;
|
|
58
|
+
case 'boolean':
|
|
59
|
+
ok = typeof v === 'boolean';
|
|
60
|
+
break;
|
|
61
|
+
case 'array': {
|
|
62
|
+
if (!Array.isArray(v)) {
|
|
63
|
+
ok = false;
|
|
64
|
+
break;
|
|
65
|
+
}
|
|
66
|
+
// Indexed loop, NOT `.every` (which skips holes): a sparse array's
|
|
67
|
+
// hole reads as `undefined`, an invalid element that intake's indexed
|
|
68
|
+
// scan rejects. Match it exactly so sparse arrays throw here rather
|
|
69
|
+
// than getting silently coerced to a missing cell by the builder.
|
|
70
|
+
ok = true;
|
|
71
|
+
for (let j = 0; j < v.length; j += 1) {
|
|
72
|
+
const el = v[j];
|
|
73
|
+
if (!((typeof el === 'number' && Number.isFinite(el)) ||
|
|
74
|
+
typeof el === 'string' ||
|
|
75
|
+
typeof el === 'boolean')) {
|
|
76
|
+
ok = false;
|
|
77
|
+
break;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
default:
|
|
83
|
+
ok = false;
|
|
84
|
+
}
|
|
85
|
+
if (!ok) {
|
|
86
|
+
// `ValidationError` (not `RangeError`) so the failure class matches the
|
|
87
|
+
// constructor's strict intake — a caller that catches `ValidationError`
|
|
88
|
+
// for bad user data sees columnar-rolling rejections identically. The
|
|
89
|
+
// value is stringified defensively: some invalid values (e.g. an array
|
|
90
|
+
// containing a `Symbol`) throw from `String(...)`, which would mask the
|
|
91
|
+
// `ValidationError` with a `TypeError`.
|
|
92
|
+
let shown;
|
|
93
|
+
try {
|
|
94
|
+
shown = String(v);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
shown = `<unstringifiable ${typeof v}>`;
|
|
98
|
+
}
|
|
99
|
+
throw new ValidationError(`${label}: result ${shown} is not a valid '${kind}' value`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
//# sourceMappingURL=column-builders.js.map
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { AlignSchema, MaterializeSchema, ArrayAggregateAppendSchema, ArrayAggregateReplaceSchema, ArrayColumnNameForSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, BaselineSchema, AggregateReducer, AggregateSchema, CollapseSchema, EventDataForSchema, EventForSchema, FirstColKind, IntervalKeyedSchema, JsonRowFormat, JoinManySchema, JoinSchema, JoinType, NumericColumnNameForSchema, NormalizedObjectRow, NormalizedRowForSchema, PivotByGroupSchema, PointRowForSchema, PrefixedJoinManySchema, PrefixedJoinSchema, ReduceResult, RenameMap, ValidatedAggregateMap } from '../schema/index.js';
|
|
2
2
|
import type { RenameSchema, RollingAlignment, RollingSchema, ColumnValue, DedupeKeep, DiffSchema, FillMapping, FillStrategy, ScalarKind, ScalarValue, SmoothMethod, SmoothAppendSchema, SmoothSchema, SelectSchema, SeriesSchema, TimeKeyedSchema, TimeSeriesJsonInput, TimeSeriesInput, TimeRangeKeyedSchema, ValueColumnKindForName, ValueColumnNameForSchema, ValueColumnsForSchema } from '../schema/index.js';
|
|
3
|
+
import { type BinSpec } from './by-column.js';
|
|
3
4
|
import { BoundedSequence } from '../sequence/bounded-sequence.js';
|
|
4
5
|
import { type TimeZoneOptions } from '../core/calendar.js';
|
|
5
6
|
import { TimeRange } from '../core/time-range.js';
|
|
@@ -519,6 +520,37 @@ export declare class TimeSeries<S extends SeriesSchema> {
|
|
|
519
520
|
*/
|
|
520
521
|
reduce(column: ValueColumnsForSchema<S>[number]['name'], reducer: AggregateReducer): ColumnValue | undefined;
|
|
521
522
|
reduce<const Mapping extends ValidatedAggregateMap<S, Mapping>>(mapping: Mapping): ReduceResult<S, Mapping>;
|
|
523
|
+
/**
|
|
524
|
+
* Example:
|
|
525
|
+
* `series.byColumn('cumDist', { width: 1000 }, { gain: { from: 'ele', using: 'sum' } })`.
|
|
526
|
+
*
|
|
527
|
+
* **Value-axis aggregation.** Where `aggregate` buckets the temporal key and
|
|
528
|
+
* `reduce` collapses the whole series to one record, `byColumn` buckets rows
|
|
529
|
+
* by the **value** of a numeric column and collapses **each value-bin** to one
|
|
530
|
+
* record. Returns an ordered array of `{ start, end, ...aggregates }` — the
|
|
531
|
+
* bin's `[start, end)` range plus the mapped reducers — *not* a `TimeSeries`,
|
|
532
|
+
* because value-bins (distance / power ranges) are not time-indexed.
|
|
533
|
+
*
|
|
534
|
+
* Two binning modes:
|
|
535
|
+
* - `{ width, origin? }` — even-width bins (`origin` defaults to `0`). Bins are
|
|
536
|
+
* emitted contiguously from the lowest to the highest occupied bin (interior
|
|
537
|
+
* empty bins included), so a histogram / profile has no gaps. A monotonic
|
|
538
|
+
* source (cumulative distance / work) yields contiguous ranges (per-km
|
|
539
|
+
* splits, elevation-vs-distance profile); a non-monotonic source (power)
|
|
540
|
+
* yields a histogram (distribution).
|
|
541
|
+
* - `{ edges }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins, bin `i` =
|
|
542
|
+
* `[eᵢ, eᵢ₊₁)` (e.g. FTP / Coggan power zones). Always emits all `n` bins.
|
|
543
|
+
*
|
|
544
|
+
* A row whose bin value is missing / non-finite (or, for `edges`, outside
|
|
545
|
+
* `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
|
|
546
|
+
* applies to the *source* columns. Empty bins emit each reducer's empty value
|
|
547
|
+
* (`count` → 0, `avg` / `min` / … → `undefined`), like an empty `aggregate`
|
|
548
|
+
* bucket. See `docs/notes/bycolumn-value-axis.md`.
|
|
549
|
+
*/
|
|
550
|
+
byColumn<const Mapping extends ValidatedAggregateMap<S, Mapping>>(col: NumericColumnNameForSchema<S>, spec: BinSpec, mapping: Mapping): Array<{
|
|
551
|
+
start: number;
|
|
552
|
+
end: number;
|
|
553
|
+
} & ReduceResult<S, Mapping>>;
|
|
522
554
|
/**
|
|
523
555
|
* Example: `series.groupBy("host")`.
|
|
524
556
|
* Partitions the series into groups keyed by the distinct values of a payload column.
|
|
@@ -900,6 +932,14 @@ export declare class TimeSeries<S extends SeriesSchema> {
|
|
|
900
932
|
* series carrying multiple entities (host, region, device id), use
|
|
901
933
|
* `series.partitionBy(col).rolling(...).collect()` to scope per
|
|
902
934
|
* entity. See {@link TimeSeries.partitionBy}.
|
|
935
|
+
*
|
|
936
|
+
* **`array`-column identity:** the window reads values from the
|
|
937
|
+
* columnar store, so on an `array`-kind source column an identity-
|
|
938
|
+
* comparing reducer (`keep`, or a custom reducer using `===` on the
|
|
939
|
+
* cell) compares the stored cell, not the original object reference
|
|
940
|
+
* from construction. Two rows that were given the *same* array object
|
|
941
|
+
* therefore read as distinct values here. Scalar columns
|
|
942
|
+
* (number / string / boolean) are unaffected (value semantics).
|
|
903
943
|
*/
|
|
904
944
|
rolling<const Mapping extends ValidatedAggregateMap<S, Mapping>>(window: DurationInput, mapping: Mapping, options?: {
|
|
905
945
|
alignment?: RollingAlignment;
|
|
@@ -5,6 +5,8 @@ import { fillOp } from './operators/fill.js';
|
|
|
5
5
|
import { mapOp } from './operators/map.js';
|
|
6
6
|
import { shiftOp } from './operators/shift.js';
|
|
7
7
|
import { collapseOp } from './operators/collapse.js';
|
|
8
|
+
import { assertColumnValuesMatchKind, columnFromValuesByKind, } from './operators/column-builders.js';
|
|
9
|
+
import { computeByColumn } from './by-column.js';
|
|
8
10
|
import { BoundedSequence } from '../sequence/bounded-sequence.js';
|
|
9
11
|
import { Interval } from '../core/interval.js';
|
|
10
12
|
import { Time } from '../core/time.js';
|
|
@@ -12,7 +14,7 @@ import { TimeRange } from '../core/time-range.js';
|
|
|
12
14
|
import { compareEventKeys } from '../core/temporal.js';
|
|
13
15
|
import { PartitionedTimeSeries } from './partitioned-time-series.js';
|
|
14
16
|
import { Sequence } from '../sequence/sequence.js';
|
|
15
|
-
import { IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
|
|
17
|
+
import { ColumnarStore, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
|
|
16
18
|
import { SeriesStore } from '../live/series-store.js';
|
|
17
19
|
import { parseDuration } from '../core/duration.js';
|
|
18
20
|
import { resolveReducer, bucketStateFor, rollingStateFor, } from '../reducers/index.js';
|
|
@@ -1370,6 +1372,37 @@ export class TimeSeries {
|
|
|
1370
1372
|
}
|
|
1371
1373
|
return result;
|
|
1372
1374
|
}
|
|
1375
|
+
/**
|
|
1376
|
+
* Example:
|
|
1377
|
+
* `series.byColumn('cumDist', { width: 1000 }, { gain: { from: 'ele', using: 'sum' } })`.
|
|
1378
|
+
*
|
|
1379
|
+
* **Value-axis aggregation.** Where `aggregate` buckets the temporal key and
|
|
1380
|
+
* `reduce` collapses the whole series to one record, `byColumn` buckets rows
|
|
1381
|
+
* by the **value** of a numeric column and collapses **each value-bin** to one
|
|
1382
|
+
* record. Returns an ordered array of `{ start, end, ...aggregates }` — the
|
|
1383
|
+
* bin's `[start, end)` range plus the mapped reducers — *not* a `TimeSeries`,
|
|
1384
|
+
* because value-bins (distance / power ranges) are not time-indexed.
|
|
1385
|
+
*
|
|
1386
|
+
* Two binning modes:
|
|
1387
|
+
* - `{ width, origin? }` — even-width bins (`origin` defaults to `0`). Bins are
|
|
1388
|
+
* emitted contiguously from the lowest to the highest occupied bin (interior
|
|
1389
|
+
* empty bins included), so a histogram / profile has no gaps. A monotonic
|
|
1390
|
+
* source (cumulative distance / work) yields contiguous ranges (per-km
|
|
1391
|
+
* splits, elevation-vs-distance profile); a non-monotonic source (power)
|
|
1392
|
+
* yields a histogram (distribution).
|
|
1393
|
+
* - `{ edges }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins, bin `i` =
|
|
1394
|
+
* `[eᵢ, eᵢ₊₁)` (e.g. FTP / Coggan power zones). Always emits all `n` bins.
|
|
1395
|
+
*
|
|
1396
|
+
* A row whose bin value is missing / non-finite (or, for `edges`, outside
|
|
1397
|
+
* `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
|
|
1398
|
+
* applies to the *source* columns. Empty bins emit each reducer's empty value
|
|
1399
|
+
* (`count` → 0, `avg` / `min` / … → `undefined`), like an empty `aggregate`
|
|
1400
|
+
* bucket. See `docs/notes/bycolumn-value-axis.md`.
|
|
1401
|
+
*/
|
|
1402
|
+
byColumn(col, spec, mapping) {
|
|
1403
|
+
const columnSpecs = normalizeAggregateColumns(this.schema, mapping);
|
|
1404
|
+
return computeByColumn(this.#store.store, col, spec, columnSpecs);
|
|
1405
|
+
}
|
|
1373
1406
|
groupBy(column, transform) {
|
|
1374
1407
|
const buckets = new Map();
|
|
1375
1408
|
for (const event of this.events) {
|
|
@@ -1979,33 +2012,34 @@ export class TimeSeries {
|
|
|
1979
2012
|
this.schema[0],
|
|
1980
2013
|
...resultColumnDefs,
|
|
1981
2014
|
]);
|
|
2015
|
+
// Columnar output path (3C): read the key + source columns straight off
|
|
2016
|
+
// the store and build the result columns directly — no `this.events`
|
|
2017
|
+
// materialization, no per-row `Event`, no row re-validation/re-pack.
|
|
2018
|
+
const store = this.#store.store;
|
|
2019
|
+
const rowCount = store.length;
|
|
2020
|
+
const sourceCols = columnSpecs.map((spec) => store.columns.get(spec.source));
|
|
1982
2021
|
const reducerStates = columnSpecs.map((spec) => isBuiltInAggregateReducer(spec.reducer)
|
|
1983
2022
|
? createRollingReducerState(spec.reducer)
|
|
1984
2023
|
: null);
|
|
1985
|
-
|
|
1986
|
-
const
|
|
2024
|
+
// Begin axis from the key buffer (replaces `this.events.map(e => e.begin())`).
|
|
2025
|
+
const beginTimes = store.keys.begin;
|
|
2026
|
+
// One value accumulator per output column; the result columns are built
|
|
2027
|
+
// from these and assembled via trusted construction below.
|
|
2028
|
+
const outValues = columnSpecs.map(() => new Array(rowCount));
|
|
1987
2029
|
let windowStart = 0;
|
|
1988
2030
|
let windowEnd = 0;
|
|
1989
2031
|
const addEvent = (index) => {
|
|
1990
|
-
const event = this.events[index];
|
|
1991
|
-
const data = event.data();
|
|
1992
2032
|
for (let i = 0; i < reducerStates.length; i++) {
|
|
1993
2033
|
const state = reducerStates[i];
|
|
1994
|
-
if (state)
|
|
1995
|
-
|
|
1996
|
-
state.add(index, data[spec.source]);
|
|
1997
|
-
}
|
|
2034
|
+
if (state)
|
|
2035
|
+
state.add(index, sourceCols[i].read(index));
|
|
1998
2036
|
}
|
|
1999
2037
|
};
|
|
2000
2038
|
const removeEvent = (index) => {
|
|
2001
|
-
const event = this.events[index];
|
|
2002
|
-
const data = event.data();
|
|
2003
2039
|
for (let i = 0; i < reducerStates.length; i++) {
|
|
2004
2040
|
const state = reducerStates[i];
|
|
2005
|
-
if (state)
|
|
2006
|
-
|
|
2007
|
-
state.remove(index, data[spec.source]);
|
|
2008
|
-
}
|
|
2041
|
+
if (state)
|
|
2042
|
+
state.remove(index, sourceCols[i].read(index));
|
|
2009
2043
|
}
|
|
2010
2044
|
};
|
|
2011
2045
|
const snapshotWindow = () => {
|
|
@@ -2015,25 +2049,33 @@ export class TimeSeries {
|
|
|
2015
2049
|
const state = reducerStates[i];
|
|
2016
2050
|
if (state)
|
|
2017
2051
|
return state.snapshot();
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2052
|
+
// Custom reducer: read the window's source values directly off the
|
|
2053
|
+
// column in arrival order — identical value list to the old
|
|
2054
|
+
// `this.events.slice(windowStart, windowEnd)` path.
|
|
2055
|
+
const col = sourceCols[i];
|
|
2056
|
+
const values = [];
|
|
2057
|
+
for (let j = windowStart; j < windowEnd; j++)
|
|
2058
|
+
values.push(col.read(j));
|
|
2024
2059
|
return applyAggregateReducer(spec.reducer, values);
|
|
2025
2060
|
});
|
|
2026
2061
|
};
|
|
2062
|
+
// Scatter one window's aggregated values across every row of an equal-key
|
|
2063
|
+
// group (replaces the per-row frozen `[key, ...aggregated]` tuple write).
|
|
2064
|
+
const writeGroup = (groupStart, groupEnd, aggregated) => {
|
|
2065
|
+
for (let index = groupStart; index < groupEnd; index++) {
|
|
2066
|
+
for (let c = 0; c < outValues.length; c++) {
|
|
2067
|
+
outValues[c][index] = aggregated[c];
|
|
2068
|
+
}
|
|
2069
|
+
}
|
|
2070
|
+
};
|
|
2027
2071
|
if (alignment === 'trailing') {
|
|
2028
|
-
for (let groupStart = 0; groupStart <
|
|
2072
|
+
for (let groupStart = 0; groupStart < rowCount;) {
|
|
2029
2073
|
const anchor = beginTimes[groupStart];
|
|
2030
2074
|
let groupEnd = groupStart + 1;
|
|
2031
|
-
while (groupEnd <
|
|
2032
|
-
beginTimes[groupEnd] === anchor) {
|
|
2075
|
+
while (groupEnd < rowCount && beginTimes[groupEnd] === anchor) {
|
|
2033
2076
|
groupEnd += 1;
|
|
2034
2077
|
}
|
|
2035
|
-
while (windowEnd <
|
|
2036
|
-
beginTimes[windowEnd] <= anchor) {
|
|
2078
|
+
while (windowEnd < rowCount && beginTimes[windowEnd] <= anchor) {
|
|
2037
2079
|
addEvent(windowEnd);
|
|
2038
2080
|
windowEnd += 1;
|
|
2039
2081
|
}
|
|
@@ -2043,22 +2085,15 @@ export class TimeSeries {
|
|
|
2043
2085
|
removeEvent(windowStart);
|
|
2044
2086
|
windowStart += 1;
|
|
2045
2087
|
}
|
|
2046
|
-
|
|
2047
|
-
for (let index = groupStart; index < groupEnd; index++) {
|
|
2048
|
-
resultRows[index] = Object.freeze([
|
|
2049
|
-
this.events[index].key(),
|
|
2050
|
-
...aggregated,
|
|
2051
|
-
]);
|
|
2052
|
-
}
|
|
2088
|
+
writeGroup(groupStart, groupEnd, snapshotWindow());
|
|
2053
2089
|
groupStart = groupEnd;
|
|
2054
2090
|
}
|
|
2055
2091
|
}
|
|
2056
2092
|
else if (alignment === 'leading') {
|
|
2057
|
-
for (let groupStart = 0; groupStart <
|
|
2093
|
+
for (let groupStart = 0; groupStart < rowCount;) {
|
|
2058
2094
|
const anchor = beginTimes[groupStart];
|
|
2059
2095
|
let groupEnd = groupStart + 1;
|
|
2060
|
-
while (groupEnd <
|
|
2061
|
-
beginTimes[groupEnd] === anchor) {
|
|
2096
|
+
while (groupEnd < rowCount && beginTimes[groupEnd] === anchor) {
|
|
2062
2097
|
groupEnd += 1;
|
|
2063
2098
|
}
|
|
2064
2099
|
const lowerBound = anchor;
|
|
@@ -2068,28 +2103,20 @@ export class TimeSeries {
|
|
|
2068
2103
|
windowStart += 1;
|
|
2069
2104
|
}
|
|
2070
2105
|
const upperBound = anchor + windowMs;
|
|
2071
|
-
while (windowEnd <
|
|
2072
|
-
beginTimes[windowEnd] < upperBound) {
|
|
2106
|
+
while (windowEnd < rowCount && beginTimes[windowEnd] < upperBound) {
|
|
2073
2107
|
addEvent(windowEnd);
|
|
2074
2108
|
windowEnd += 1;
|
|
2075
2109
|
}
|
|
2076
|
-
|
|
2077
|
-
for (let index = groupStart; index < groupEnd; index++) {
|
|
2078
|
-
resultRows[index] = Object.freeze([
|
|
2079
|
-
this.events[index].key(),
|
|
2080
|
-
...aggregated,
|
|
2081
|
-
]);
|
|
2082
|
-
}
|
|
2110
|
+
writeGroup(groupStart, groupEnd, snapshotWindow());
|
|
2083
2111
|
groupStart = groupEnd;
|
|
2084
2112
|
}
|
|
2085
2113
|
}
|
|
2086
2114
|
else {
|
|
2087
2115
|
const halfWindow = windowMs / 2;
|
|
2088
|
-
for (let groupStart = 0; groupStart <
|
|
2116
|
+
for (let groupStart = 0; groupStart < rowCount;) {
|
|
2089
2117
|
const anchor = beginTimes[groupStart];
|
|
2090
2118
|
let groupEnd = groupStart + 1;
|
|
2091
|
-
while (groupEnd <
|
|
2092
|
-
beginTimes[groupEnd] === anchor) {
|
|
2119
|
+
while (groupEnd < rowCount && beginTimes[groupEnd] === anchor) {
|
|
2093
2120
|
groupEnd += 1;
|
|
2094
2121
|
}
|
|
2095
2122
|
const lowerBound = anchor - halfWindow;
|
|
@@ -2099,26 +2126,36 @@ export class TimeSeries {
|
|
|
2099
2126
|
windowStart += 1;
|
|
2100
2127
|
}
|
|
2101
2128
|
const upperBound = anchor + halfWindow;
|
|
2102
|
-
while (windowEnd <
|
|
2103
|
-
beginTimes[windowEnd] < upperBound) {
|
|
2129
|
+
while (windowEnd < rowCount && beginTimes[windowEnd] < upperBound) {
|
|
2104
2130
|
addEvent(windowEnd);
|
|
2105
2131
|
windowEnd += 1;
|
|
2106
2132
|
}
|
|
2107
|
-
|
|
2108
|
-
for (let index = groupStart; index < groupEnd; index++) {
|
|
2109
|
-
resultRows[index] = Object.freeze([
|
|
2110
|
-
this.events[index].key(),
|
|
2111
|
-
...aggregated,
|
|
2112
|
-
]);
|
|
2113
|
-
}
|
|
2133
|
+
writeGroup(groupStart, groupEnd, snapshotWindow());
|
|
2114
2134
|
groupStart = groupEnd;
|
|
2115
2135
|
}
|
|
2116
2136
|
}
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
2120
|
-
|
|
2121
|
-
|
|
2137
|
+
// Assemble the result columns from the per-column accumulators and wrap via
|
|
2138
|
+
// trusted construction — the key column passes through zero-copy, and there
|
|
2139
|
+
// is no row re-validation / re-pack (the win this path exists for).
|
|
2140
|
+
const outColumns = new Map();
|
|
2141
|
+
for (let c = 0; c < columnSpecs.length; c++) {
|
|
2142
|
+
const kind = resultColumnDefs[c].kind;
|
|
2143
|
+
const values = outValues[c];
|
|
2144
|
+
// The old event-based path re-packed via the constructor's strict intake,
|
|
2145
|
+
// which rejected any defined result that didn't match the declared output
|
|
2146
|
+
// kind — a non-finite number (a `sum` overflow), or a wrong-typed value
|
|
2147
|
+
// (a custom reducer / `kind` override producing a string for a number
|
|
2148
|
+
// column, etc.). Trusted construction skips intake, and the `*FromArray`
|
|
2149
|
+
// builders silently coerce a kind mismatch to a *missing* cell — so
|
|
2150
|
+
// re-assert the same contract here (same value rules AND the same
|
|
2151
|
+
// `ValidationError` class as intake), keeping packed columns clean.
|
|
2152
|
+
// Missing cells (`undefined`, e.g. the minSamples warm-up) are
|
|
2153
|
+
// unaffected. (#225 Codex finding.)
|
|
2154
|
+
assertColumnValuesMatchKind(kind, values, `rolling column '${columnSpecs[c].output}'`);
|
|
2155
|
+
outColumns.set(columnSpecs[c].output, columnFromValuesByKind(kind, values));
|
|
2156
|
+
}
|
|
2157
|
+
const outStore = ColumnarStore.fromTrustedStore(resultSchema, store.keys, outColumns);
|
|
2158
|
+
return TimeSeries.#fromTrustedStore(this.name, resultSchema, outStore);
|
|
2122
2159
|
}
|
|
2123
2160
|
/**
|
|
2124
2161
|
* Example: `series.smooth("value", "ema", { alpha: 0.2 })`.
|