pond-ts 0.26.0 → 0.28.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 +42 -3
- 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 +6 -3
- package/dist/batch/operators/column-builders.js +6 -3
- package/dist/batch/rolling-by-column.d.ts +36 -0
- package/dist/batch/rolling-by-column.js +102 -0
- package/dist/batch/time-series.d.ts +85 -1
- package/dist/batch/time-series.js +104 -1
- 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.28.0...HEAD
|
|
11
|
+
[0.28.0]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...v0.28.0
|
|
12
|
+
[0.27.0]: https://github.com/pjm17971/pond-ts/compare/v0.26.0...v0.27.0
|
|
11
13
|
[0.26.0]: https://github.com/pjm17971/pond-ts/compare/v0.25.0...v0.26.0
|
|
12
14
|
[0.25.0]: https://github.com/pjm17971/pond-ts/compare/v0.24.0...v0.25.0
|
|
13
15
|
[0.24.0]: https://github.com/pjm17971/pond-ts/compare/v0.23.0...v0.24.0
|
|
@@ -20,7 +22,44 @@ type-level changes; patch bumps are strictly additive.
|
|
|
20
22
|
|
|
21
23
|
## [Unreleased]
|
|
22
24
|
|
|
23
|
-
## [0.
|
|
25
|
+
## [0.28.0] — 2026-06-17
|
|
26
|
+
|
|
27
|
+
### Added
|
|
28
|
+
|
|
29
|
+
- **`TimeSeries.rollingByColumn(col, { radius }, mapping)` — windowed value-axis
|
|
30
|
+
aggregation.** The sliding-window sibling of `byColumn`: slides a centered
|
|
31
|
+
`±radius` window along a **non-decreasing** numeric column and reduces it at
|
|
32
|
+
every row, returning one record per row (positionally aligned with the
|
|
33
|
+
series). Where `byColumn` collapses rows into disjoint value-bins (the
|
|
34
|
+
value-axis analogue of `aggregate`), `rollingByColumn` is the value-axis
|
|
35
|
+
analogue of `rolling`. Built for windowed-percentile bands over a derived axis
|
|
36
|
+
(e.g. a spread band over cumulative distance). A missing/non-finite axis row is
|
|
37
|
+
excluded from every window and emits each reducer's empty value. O(n) two-pointer
|
|
38
|
+
sweep. See `docs/notes/rolling-by-column.md`.
|
|
39
|
+
- **`TimeSeries.withColumn(name, values)` — attach a computed numeric column.**
|
|
40
|
+
Appends a `Float64Array` / `(number | undefined)[]` as a new `number` column
|
|
41
|
+
(the schema type widens to include it), so a derived array — cumulative
|
|
42
|
+
distance, speed, gradient — can re-enter the pond pipeline as a real column
|
|
43
|
+
that `aggregate` / `byColumn` / `rollingByColumn` / `column(name)` can
|
|
44
|
+
reference. Existing key + value columns are shared by reference (zero-copy);
|
|
45
|
+
only the new column is added. `values` must match `series.length`; defined
|
|
46
|
+
cells are validated against the numeric intake contract (**non-finite is
|
|
47
|
+
rejected** — pass `undefined` for a missing cell, not `NaN`).
|
|
48
|
+
|
|
49
|
+
### Added
|
|
50
|
+
|
|
51
|
+
- **`TimeSeries.byColumn(col, { width, origin? } | { edges }, mapping)` —
|
|
52
|
+
value-axis aggregation.** Where `aggregate` buckets the temporal key,
|
|
53
|
+
`byColumn` buckets rows by the **value** of a numeric column and reduces each
|
|
54
|
+
bin, returning an ordered array of `{ start, end, ...aggregates }` records
|
|
55
|
+
(one per bin) — not a `TimeSeries`, since value-bins (distance / power ranges)
|
|
56
|
+
aren't time-indexed. `{ width }` gives even bins emitted contiguously from the
|
|
57
|
+
lowest to highest occupied bin (monotonic source → splits / profile;
|
|
58
|
+
non-monotonic → histogram); `{ edges }` gives explicit ascending bins (e.g.
|
|
59
|
+
power zones). Reuses the reducer mapping + non-finite policy. Rows whose bin
|
|
60
|
+
value is missing / non-finite (or, for `edges`, out of range) are dropped;
|
|
61
|
+
empty bins emit the reducer's empty value; a non-finite / wrong-kind reducer
|
|
62
|
+
result throws `ValidationError`. See `docs/notes/bycolumn-value-axis.md`.
|
|
24
63
|
|
|
25
64
|
### Changed
|
|
26
65
|
|
|
@@ -36,7 +75,7 @@ type-level changes; patch bumps are strictly additive.
|
|
|
36
75
|
- **Behavior note — `array` columns:** an identity-comparing reducer (`keep`,
|
|
37
76
|
or a custom reducer using `===` on the cell) on an `array`-kind source
|
|
38
77
|
column now compares the value stored in the column, not the original object
|
|
39
|
-
reference passed at construction. Two rows given the
|
|
78
|
+
reference passed at construction. Two rows given the _same_ array object
|
|
40
79
|
therefore read as distinct. Scalar columns (number / string / boolean) are
|
|
41
80
|
unaffected. A non-finite or wrong-kind reducer result is still rejected with
|
|
42
81
|
a `ValidationError`, exactly as the constructor's intake did.
|
|
@@ -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
|
|
@@ -2,9 +2,12 @@ import { type Column } from '../../columnar/index.js';
|
|
|
2
2
|
/**
|
|
3
3
|
* Build a typed {@link Column} from a per-row value array, dispatching on the
|
|
4
4
|
* column's `kind`. An `undefined` cell becomes missing (its validity bit is
|
|
5
|
-
* left unset, so `read(i)` returns `undefined`).
|
|
6
|
-
*
|
|
7
|
-
*
|
|
5
|
+
* left unset, so `read(i)` returns `undefined`). These builders do **not**
|
|
6
|
+
* enforce the intake contract: `float64ColumnFromArray` *stores* a non-finite
|
|
7
|
+
* number (flagging the column non-finite for the guarded reducer path) rather
|
|
8
|
+
* than rejecting it, and the `*FromArray` builders coerce a kind mismatch to a
|
|
9
|
+
* missing cell. A caller that must uphold intake (finite-or-missing, no NaN in
|
|
10
|
+
* a packed numeric column) calls {@link assertColumnValuesMatchKind} first.
|
|
8
11
|
*
|
|
9
12
|
* This is the shared form of the kind→builder dispatch that `fill`
|
|
10
13
|
* (`buildFilledColumn`), `map` (its inline builder), and `collapse`
|
|
@@ -3,9 +3,12 @@ import { ValidationError } from '../../core/errors.js';
|
|
|
3
3
|
/**
|
|
4
4
|
* Build a typed {@link Column} from a per-row value array, dispatching on the
|
|
5
5
|
* column's `kind`. An `undefined` cell becomes missing (its validity bit is
|
|
6
|
-
* left unset, so `read(i)` returns `undefined`).
|
|
7
|
-
*
|
|
8
|
-
*
|
|
6
|
+
* left unset, so `read(i)` returns `undefined`). These builders do **not**
|
|
7
|
+
* enforce the intake contract: `float64ColumnFromArray` *stores* a non-finite
|
|
8
|
+
* number (flagging the column non-finite for the guarded reducer path) rather
|
|
9
|
+
* than rejecting it, and the `*FromArray` builders coerce a kind mismatch to a
|
|
10
|
+
* missing cell. A caller that must uphold intake (finite-or-missing, no NaN in
|
|
11
|
+
* a packed numeric column) calls {@link assertColumnValuesMatchKind} first.
|
|
9
12
|
*
|
|
10
13
|
* This is the shared form of the kind→builder dispatch that `fill`
|
|
11
14
|
* (`buildFilledColumn`), `map` (its inline builder), and `collapse`
|
|
@@ -0,0 +1,36 @@
|
|
|
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
|
+
* Window spec for {@link TimeSeries.rollingByColumn}: a **centered** window of
|
|
6
|
+
* half-width `radius`, in the axis column's own units. The window for a row is
|
|
7
|
+
* every row whose axis value lies within `±radius` of it. See
|
|
8
|
+
* `docs/notes/rolling-by-column.md`.
|
|
9
|
+
*/
|
|
10
|
+
export type WindowSpec = {
|
|
11
|
+
radius: number;
|
|
12
|
+
};
|
|
13
|
+
/** One windowed record: the mapped aggregates over the window centered at a row. */
|
|
14
|
+
export type WindowRecord = Record<string, ColumnValue | undefined>;
|
|
15
|
+
/**
|
|
16
|
+
* Windowed value-axis aggregation runtime — the sliding-window sibling of
|
|
17
|
+
* {@link computeByColumn}. For each row, reduces (via `columnSpecs`) the rows
|
|
18
|
+
* whose `axisColName` value lies within `±spec.radius` of that row's value,
|
|
19
|
+
* returning one {@link WindowRecord} per row, **positionally aligned with the
|
|
20
|
+
* store** (`out[i]` is the window centered at row `i`).
|
|
21
|
+
*
|
|
22
|
+
* The axis column must be **non-decreasing** — that ordering is what makes a
|
|
23
|
+
* sliding window meaningful (vs `byColumn`'s order-free group-by) and is what
|
|
24
|
+
* lets the window advance as a single O(n) two-pointer rather than a per-row
|
|
25
|
+
* range scan. A row whose axis value is missing / non-finite can't be placed in
|
|
26
|
+
* the ordering: it is excluded from every window, and its own output slot gets
|
|
27
|
+
* each reducer's empty snapshot (so the result stays positionally aligned). The
|
|
28
|
+
* reducer non-finite policy still applies to the *source* columns.
|
|
29
|
+
*
|
|
30
|
+
* Reads straight off the columnar store (`Column.read(i)`, no event
|
|
31
|
+
* materialization). Uses `rollingStateFor` (add/remove/snapshot) rather than the
|
|
32
|
+
* append-only `bucketStateFor`, because the window is a moving multiset; the two
|
|
33
|
+
* pointers `add` rows entering the right edge and `remove` rows leaving the left.
|
|
34
|
+
*/
|
|
35
|
+
export declare function computeRollingByColumn(store: ColumnarStore<ColumnSchema>, axisColName: string, spec: WindowSpec, columnSpecs: ReadonlyArray<AggregateColumnSpec>): WindowRecord[];
|
|
36
|
+
//# sourceMappingURL=rolling-by-column.d.ts.map
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { rollingStateFor } from '../reducers/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Windowed value-axis aggregation runtime — the sliding-window sibling of
|
|
4
|
+
* {@link computeByColumn}. For each row, reduces (via `columnSpecs`) the rows
|
|
5
|
+
* whose `axisColName` value lies within `±spec.radius` of that row's value,
|
|
6
|
+
* returning one {@link WindowRecord} per row, **positionally aligned with the
|
|
7
|
+
* store** (`out[i]` is the window centered at row `i`).
|
|
8
|
+
*
|
|
9
|
+
* The axis column must be **non-decreasing** — that ordering is what makes a
|
|
10
|
+
* sliding window meaningful (vs `byColumn`'s order-free group-by) and is what
|
|
11
|
+
* lets the window advance as a single O(n) two-pointer rather than a per-row
|
|
12
|
+
* range scan. A row whose axis value is missing / non-finite can't be placed in
|
|
13
|
+
* the ordering: it is excluded from every window, and its own output slot gets
|
|
14
|
+
* each reducer's empty snapshot (so the result stays positionally aligned). The
|
|
15
|
+
* reducer non-finite policy still applies to the *source* columns.
|
|
16
|
+
*
|
|
17
|
+
* Reads straight off the columnar store (`Column.read(i)`, no event
|
|
18
|
+
* materialization). Uses `rollingStateFor` (add/remove/snapshot) rather than the
|
|
19
|
+
* append-only `bucketStateFor`, because the window is a moving multiset; the two
|
|
20
|
+
* pointers `add` rows entering the right edge and `remove` rows leaving the left.
|
|
21
|
+
*/
|
|
22
|
+
export function computeRollingByColumn(store, axisColName, spec, columnSpecs) {
|
|
23
|
+
const axisCol = store.columns.get(axisColName);
|
|
24
|
+
if (axisCol === undefined) {
|
|
25
|
+
throw new RangeError(`rollingByColumn: unknown column '${axisColName}'`);
|
|
26
|
+
}
|
|
27
|
+
if (axisCol.kind !== 'number') {
|
|
28
|
+
throw new TypeError(`rollingByColumn: column '${axisColName}' must be a number column (got '${axisCol.kind}')`);
|
|
29
|
+
}
|
|
30
|
+
const { radius } = spec;
|
|
31
|
+
if (!Number.isFinite(radius) || radius <= 0) {
|
|
32
|
+
throw new RangeError('rollingByColumn: radius must be a positive finite number');
|
|
33
|
+
}
|
|
34
|
+
const sourceCols = columnSpecs.map((s) => store.columns.get(s.source));
|
|
35
|
+
const n = store.length;
|
|
36
|
+
// Compact the finite-axis rows in row order: `ax[k]` is the axis value, `idx[k]`
|
|
37
|
+
// the real row index. Validate non-decreasing — a sliding window over an
|
|
38
|
+
// unsorted axis is meaningless, and a descending step would break the
|
|
39
|
+
// monotonic two-pointer below (it only ever moves `lo`/`hi` to the right).
|
|
40
|
+
const ax = new Float64Array(n);
|
|
41
|
+
const idx = new Int32Array(n);
|
|
42
|
+
let m = 0;
|
|
43
|
+
for (let i = 0; i < n; i += 1) {
|
|
44
|
+
const av = axisCol.read(i);
|
|
45
|
+
if (typeof av !== 'number' || !Number.isFinite(av))
|
|
46
|
+
continue;
|
|
47
|
+
if (m > 0 && av < ax[m - 1]) {
|
|
48
|
+
throw new RangeError(`rollingByColumn: axis column '${axisColName}' must be non-decreasing; row ${i} (${av}) < previous (${ax[m - 1]})`);
|
|
49
|
+
}
|
|
50
|
+
ax[m] = av;
|
|
51
|
+
idx[m] = i;
|
|
52
|
+
m += 1;
|
|
53
|
+
}
|
|
54
|
+
// One shared rolling state per output column, maintained incrementally across
|
|
55
|
+
// the whole sweep — this is what makes it O(n) rather than O(n · window).
|
|
56
|
+
const states = columnSpecs.map((s) => rollingStateFor(s.reducer));
|
|
57
|
+
const specCount = columnSpecs.length;
|
|
58
|
+
const out = new Array(n);
|
|
59
|
+
let lo = 0;
|
|
60
|
+
let hi = 0; // the window currently holds compact positions [lo, hi)
|
|
61
|
+
for (let i = 0; i < n; i += 1) {
|
|
62
|
+
const av = axisCol.read(i);
|
|
63
|
+
if (typeof av !== 'number' || !Number.isFinite(av)) {
|
|
64
|
+
// No axis position → empty window. A FRESH empty snapshot per row (per
|
|
65
|
+
// spec), not a shared/cached value: an array-kind reducer would otherwise
|
|
66
|
+
// alias one `[]` across rows. Matches byColumn's empty-bin handling.
|
|
67
|
+
const rec = {};
|
|
68
|
+
for (let c = 0; c < specCount; c += 1) {
|
|
69
|
+
rec[columnSpecs[c].output] = rollingStateFor(columnSpecs[c].reducer).snapshot();
|
|
70
|
+
}
|
|
71
|
+
out[i] = rec;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
const wlo = av - radius;
|
|
75
|
+
const whi = av + radius;
|
|
76
|
+
// Expand the right edge: add finite-axis rows with `ax ≤ center + radius`.
|
|
77
|
+
while (hi < m && ax[hi] <= whi) {
|
|
78
|
+
const r = idx[hi];
|
|
79
|
+
for (let c = 0; c < specCount; c += 1) {
|
|
80
|
+
states[c].add(r, sourceCols[c].read(r));
|
|
81
|
+
}
|
|
82
|
+
hi += 1;
|
|
83
|
+
}
|
|
84
|
+
// Contract the left edge: drop rows with `ax < center − radius`. The center
|
|
85
|
+
// row itself satisfies `wlo ≤ av ≤ whi`, so `lo` never passes it (`lo < hi`
|
|
86
|
+
// always holds here) and the window is non-empty.
|
|
87
|
+
while (lo < hi && ax[lo] < wlo) {
|
|
88
|
+
const r = idx[lo];
|
|
89
|
+
for (let c = 0; c < specCount; c += 1) {
|
|
90
|
+
states[c].remove(r, sourceCols[c].read(r));
|
|
91
|
+
}
|
|
92
|
+
lo += 1;
|
|
93
|
+
}
|
|
94
|
+
const rec = {};
|
|
95
|
+
for (let c = 0; c < specCount; c += 1) {
|
|
96
|
+
rec[columnSpecs[c].output] = states[c].snapshot();
|
|
97
|
+
}
|
|
98
|
+
out[i] = rec;
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
}
|
|
102
|
+
//# sourceMappingURL=rolling-by-column.js.map
|
|
@@ -1,5 +1,7 @@
|
|
|
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';
|
|
1
|
+
import type { AlignSchema, MaterializeSchema, ArrayAggregateAppendSchema, ArrayAggregateReplaceSchema, ArrayColumnNameForSchema, ArrayExplodeAppendSchema, ArrayExplodeReplaceSchema, BaselineSchema, AggregateReducer, AggregateSchema, AppendColumn, 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';
|
|
4
|
+
import { type WindowSpec } from './rolling-by-column.js';
|
|
3
5
|
import { BoundedSequence } from '../sequence/bounded-sequence.js';
|
|
4
6
|
import { type TimeZoneOptions } from '../core/calendar.js';
|
|
5
7
|
import { TimeRange } from '../core/time-range.js';
|
|
@@ -519,6 +521,63 @@ export declare class TimeSeries<S extends SeriesSchema> {
|
|
|
519
521
|
*/
|
|
520
522
|
reduce(column: ValueColumnsForSchema<S>[number]['name'], reducer: AggregateReducer): ColumnValue | undefined;
|
|
521
523
|
reduce<const Mapping extends ValidatedAggregateMap<S, Mapping>>(mapping: Mapping): ReduceResult<S, Mapping>;
|
|
524
|
+
/**
|
|
525
|
+
* Example:
|
|
526
|
+
* `series.byColumn('cumDist', { width: 1000 }, { gain: { from: 'ele', using: 'sum' } })`.
|
|
527
|
+
*
|
|
528
|
+
* **Value-axis aggregation.** Where `aggregate` buckets the temporal key and
|
|
529
|
+
* `reduce` collapses the whole series to one record, `byColumn` buckets rows
|
|
530
|
+
* by the **value** of a numeric column and collapses **each value-bin** to one
|
|
531
|
+
* record. Returns an ordered array of `{ start, end, ...aggregates }` — the
|
|
532
|
+
* bin's `[start, end)` range plus the mapped reducers — *not* a `TimeSeries`,
|
|
533
|
+
* because value-bins (distance / power ranges) are not time-indexed.
|
|
534
|
+
*
|
|
535
|
+
* Two binning modes:
|
|
536
|
+
* - `{ width, origin? }` — even-width bins (`origin` defaults to `0`). Bins are
|
|
537
|
+
* emitted contiguously from the lowest to the highest occupied bin (interior
|
|
538
|
+
* empty bins included), so a histogram / profile has no gaps. A monotonic
|
|
539
|
+
* source (cumulative distance / work) yields contiguous ranges (per-km
|
|
540
|
+
* splits, elevation-vs-distance profile); a non-monotonic source (power)
|
|
541
|
+
* yields a histogram (distribution).
|
|
542
|
+
* - `{ edges }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins, bin `i` =
|
|
543
|
+
* `[eᵢ, eᵢ₊₁)` (e.g. FTP / Coggan power zones). Always emits all `n` bins.
|
|
544
|
+
*
|
|
545
|
+
* A row whose bin value is missing / non-finite (or, for `edges`, outside
|
|
546
|
+
* `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
|
|
547
|
+
* applies to the *source* columns. Empty bins emit each reducer's empty value
|
|
548
|
+
* (`count` → 0, `avg` / `min` / … → `undefined`), like an empty `aggregate`
|
|
549
|
+
* bucket. See `docs/notes/bycolumn-value-axis.md`.
|
|
550
|
+
*/
|
|
551
|
+
byColumn<const Mapping extends ValidatedAggregateMap<S, Mapping>>(col: NumericColumnNameForSchema<S>, spec: BinSpec, mapping: Mapping): Array<{
|
|
552
|
+
start: number;
|
|
553
|
+
end: number;
|
|
554
|
+
} & ReduceResult<S, Mapping>>;
|
|
555
|
+
/**
|
|
556
|
+
* Example:
|
|
557
|
+
* `series.rollingByColumn('cumDist', { radius: 120 }, { lo: { from: 'speed', using: 'p5' }, hi: { from: 'speed', using: 'p95' } })`.
|
|
558
|
+
*
|
|
559
|
+
* **Windowed value-axis aggregation — the sliding-window sibling of
|
|
560
|
+
* `byColumn`.** Where `byColumn` collapses rows into disjoint value-bins (the
|
|
561
|
+
* value-axis analogue of `aggregate`), `rollingByColumn` slides a **centered
|
|
562
|
+
* window** along a value axis and reduces the window at every row (the
|
|
563
|
+
* value-axis analogue of `rolling`). For each row it reduces the rows whose
|
|
564
|
+
* `col` value lies within `±radius` of that row's value, and returns **one
|
|
565
|
+
* record per row, positionally aligned with the series** (`out[i]` is the
|
|
566
|
+
* window centered at row `i`) — *not* a `TimeSeries`, and with no `start`/`end`
|
|
567
|
+
* range (the caller already has the axis column to zip against).
|
|
568
|
+
*
|
|
569
|
+
* `col` must be a **non-decreasing** numeric column — the ordering is what
|
|
570
|
+
* makes a sliding window meaningful (vs `byColumn`'s order-free group-by) and
|
|
571
|
+
* is enforced (a descending step throws). A row whose `col` value is missing /
|
|
572
|
+
* non-finite is excluded from every window and its own slot gets each
|
|
573
|
+
* reducer's empty snapshot, so the result stays positionally aligned. The
|
|
574
|
+
* reducer non-finite policy still applies to the *source* columns.
|
|
575
|
+
*
|
|
576
|
+
* The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
|
|
577
|
+
* radius`); a single O(n) two-pointer sweep maintains the window. See
|
|
578
|
+
* `docs/notes/rolling-by-column.md`.
|
|
579
|
+
*/
|
|
580
|
+
rollingByColumn<const Mapping extends ValidatedAggregateMap<S, Mapping>>(col: NumericColumnNameForSchema<S>, spec: WindowSpec, mapping: Mapping): Array<ReduceResult<S, Mapping>>;
|
|
522
581
|
/**
|
|
523
582
|
* Example: `series.groupBy("host")`.
|
|
524
583
|
* Partitions the series into groups keyed by the distinct values of a payload column.
|
|
@@ -1080,6 +1139,31 @@ export declare class TimeSeries<S extends SeriesSchema> {
|
|
|
1080
1139
|
select<const Keys extends readonly (keyof EventDataForSchema<S>)[]>(...keys: Keys): TimeSeries<SelectSchema<S, Keys[number] & string>>;
|
|
1081
1140
|
/** Example: `series.rename({ cpu: "usage" })`. Returns a new series with payload field names renamed according to the supplied mapping. */
|
|
1082
1141
|
rename<const Mapping extends RenameMap<EventDataForSchema<S>>>(mapping: Mapping): TimeSeries<RenameSchema<S, Mapping>>;
|
|
1142
|
+
/**
|
|
1143
|
+
* Example:
|
|
1144
|
+
* `series.withColumn('cumDist', cumulativeDistances)` (a `Float64Array`).
|
|
1145
|
+
*
|
|
1146
|
+
* **Attach a computed numeric column.** Returns a new series with `values`
|
|
1147
|
+
* appended as a new `number` column named `name`, so downstream pond ops
|
|
1148
|
+
* (`aggregate`, `byColumn`, `rollingByColumn`, `column(name)`) can reference
|
|
1149
|
+
* it. The existing key + value columns are shared by reference (zero-copy);
|
|
1150
|
+
* only the new column is added. This is the seam that lets a derived array
|
|
1151
|
+
* (cumulative distance, speed, gradient) re-enter the pond pipeline as a real
|
|
1152
|
+
* column instead of staying a side-channel.
|
|
1153
|
+
*
|
|
1154
|
+
* `values` is a `Float64Array` (dense) or a `(number | undefined)[]` (where
|
|
1155
|
+
* `undefined` marks a missing cell), and must have exactly `series.length`
|
|
1156
|
+
* entries. Defined values are validated against the numeric intake contract —
|
|
1157
|
+
* **non-finite (`NaN` / `±Infinity`) is rejected** (a `ValidationError`,
|
|
1158
|
+
* matching construction), so packed numeric columns stay NaN-free; pass
|
|
1159
|
+
* `undefined` for a missing cell, never `NaN`. `name` must not collide with an
|
|
1160
|
+
* existing column.
|
|
1161
|
+
*
|
|
1162
|
+
* This is the *validated* attach. A trusted bulk-construction path
|
|
1163
|
+
* (`fromTrustedColumns`, skipping the finite scan) is a deferred sibling for
|
|
1164
|
+
* when a perf-critical consumer earns it.
|
|
1165
|
+
*/
|
|
1166
|
+
withColumn<const Name extends string>(name: Name, values: ReadonlyArray<number | undefined> | Float64Array): TimeSeries<AppendColumn<S, Name, 'number'>>;
|
|
1083
1167
|
/** Example: `series.collapse(["in", "out"], "avg", fn)`. Collapses selected payload fields into a single derived field across each event in the series. */
|
|
1084
1168
|
collapse<const Keys extends readonly (keyof EventDataForSchema<S>)[], Name extends string, R extends ScalarValue>(keys: Keys, output: Name, reducer: (values: Pick<EventDataForSchema<S>, Keys[number]>) => R): TimeSeries<CollapseSchema<S, Keys[number] & string, Name, R>>;
|
|
1085
1169
|
collapse<const Keys extends readonly (keyof EventDataForSchema<S>)[], Name extends string, R extends ScalarValue>(keys: Keys, output: Name, reducer: (values: Pick<EventDataForSchema<S>, Keys[number]>) => R, options: {
|
|
@@ -6,6 +6,8 @@ import { mapOp } from './operators/map.js';
|
|
|
6
6
|
import { shiftOp } from './operators/shift.js';
|
|
7
7
|
import { collapseOp } from './operators/collapse.js';
|
|
8
8
|
import { assertColumnValuesMatchKind, columnFromValuesByKind, } from './operators/column-builders.js';
|
|
9
|
+
import { computeByColumn } from './by-column.js';
|
|
10
|
+
import { computeRollingByColumn, } from './rolling-by-column.js';
|
|
9
11
|
import { BoundedSequence } from '../sequence/bounded-sequence.js';
|
|
10
12
|
import { Interval } from '../core/interval.js';
|
|
11
13
|
import { Time } from '../core/time.js';
|
|
@@ -13,7 +15,7 @@ import { TimeRange } from '../core/time-range.js';
|
|
|
13
15
|
import { compareEventKeys } from '../core/temporal.js';
|
|
14
16
|
import { PartitionedTimeSeries } from './partitioned-time-series.js';
|
|
15
17
|
import { Sequence } from '../sequence/sequence.js';
|
|
16
|
-
import { ColumnarStore, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
|
|
18
|
+
import { ColumnarStore, IntervalKeyColumn, TimeKeyColumn, TimeRangeKeyColumn, float64ColumnFromArray, stringColumnFromArray, withColumnAppended, withColumnsRenamed, withColumnsSelected, withKeyColumn, withRowRange, withRowSelection, } from '../columnar/index.js';
|
|
17
19
|
import { SeriesStore } from '../live/series-store.js';
|
|
18
20
|
import { parseDuration } from '../core/duration.js';
|
|
19
21
|
import { resolveReducer, bucketStateFor, rollingStateFor, } from '../reducers/index.js';
|
|
@@ -1371,6 +1373,66 @@ export class TimeSeries {
|
|
|
1371
1373
|
}
|
|
1372
1374
|
return result;
|
|
1373
1375
|
}
|
|
1376
|
+
/**
|
|
1377
|
+
* Example:
|
|
1378
|
+
* `series.byColumn('cumDist', { width: 1000 }, { gain: { from: 'ele', using: 'sum' } })`.
|
|
1379
|
+
*
|
|
1380
|
+
* **Value-axis aggregation.** Where `aggregate` buckets the temporal key and
|
|
1381
|
+
* `reduce` collapses the whole series to one record, `byColumn` buckets rows
|
|
1382
|
+
* by the **value** of a numeric column and collapses **each value-bin** to one
|
|
1383
|
+
* record. Returns an ordered array of `{ start, end, ...aggregates }` — the
|
|
1384
|
+
* bin's `[start, end)` range plus the mapped reducers — *not* a `TimeSeries`,
|
|
1385
|
+
* because value-bins (distance / power ranges) are not time-indexed.
|
|
1386
|
+
*
|
|
1387
|
+
* Two binning modes:
|
|
1388
|
+
* - `{ width, origin? }` — even-width bins (`origin` defaults to `0`). Bins are
|
|
1389
|
+
* emitted contiguously from the lowest to the highest occupied bin (interior
|
|
1390
|
+
* empty bins included), so a histogram / profile has no gaps. A monotonic
|
|
1391
|
+
* source (cumulative distance / work) yields contiguous ranges (per-km
|
|
1392
|
+
* splits, elevation-vs-distance profile); a non-monotonic source (power)
|
|
1393
|
+
* yields a histogram (distribution).
|
|
1394
|
+
* - `{ edges }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins, bin `i` =
|
|
1395
|
+
* `[eᵢ, eᵢ₊₁)` (e.g. FTP / Coggan power zones). Always emits all `n` bins.
|
|
1396
|
+
*
|
|
1397
|
+
* A row whose bin value is missing / non-finite (or, for `edges`, outside
|
|
1398
|
+
* `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
|
|
1399
|
+
* applies to the *source* columns. Empty bins emit each reducer's empty value
|
|
1400
|
+
* (`count` → 0, `avg` / `min` / … → `undefined`), like an empty `aggregate`
|
|
1401
|
+
* bucket. See `docs/notes/bycolumn-value-axis.md`.
|
|
1402
|
+
*/
|
|
1403
|
+
byColumn(col, spec, mapping) {
|
|
1404
|
+
const columnSpecs = normalizeAggregateColumns(this.schema, mapping);
|
|
1405
|
+
return computeByColumn(this.#store.store, col, spec, columnSpecs);
|
|
1406
|
+
}
|
|
1407
|
+
/**
|
|
1408
|
+
* Example:
|
|
1409
|
+
* `series.rollingByColumn('cumDist', { radius: 120 }, { lo: { from: 'speed', using: 'p5' }, hi: { from: 'speed', using: 'p95' } })`.
|
|
1410
|
+
*
|
|
1411
|
+
* **Windowed value-axis aggregation — the sliding-window sibling of
|
|
1412
|
+
* `byColumn`.** Where `byColumn` collapses rows into disjoint value-bins (the
|
|
1413
|
+
* value-axis analogue of `aggregate`), `rollingByColumn` slides a **centered
|
|
1414
|
+
* window** along a value axis and reduces the window at every row (the
|
|
1415
|
+
* value-axis analogue of `rolling`). For each row it reduces the rows whose
|
|
1416
|
+
* `col` value lies within `±radius` of that row's value, and returns **one
|
|
1417
|
+
* record per row, positionally aligned with the series** (`out[i]` is the
|
|
1418
|
+
* window centered at row `i`) — *not* a `TimeSeries`, and with no `start`/`end`
|
|
1419
|
+
* range (the caller already has the axis column to zip against).
|
|
1420
|
+
*
|
|
1421
|
+
* `col` must be a **non-decreasing** numeric column — the ordering is what
|
|
1422
|
+
* makes a sliding window meaningful (vs `byColumn`'s order-free group-by) and
|
|
1423
|
+
* is enforced (a descending step throws). A row whose `col` value is missing /
|
|
1424
|
+
* non-finite is excluded from every window and its own slot gets each
|
|
1425
|
+
* reducer's empty snapshot, so the result stays positionally aligned. The
|
|
1426
|
+
* reducer non-finite policy still applies to the *source* columns.
|
|
1427
|
+
*
|
|
1428
|
+
* The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
|
|
1429
|
+
* radius`); a single O(n) two-pointer sweep maintains the window. See
|
|
1430
|
+
* `docs/notes/rolling-by-column.md`.
|
|
1431
|
+
*/
|
|
1432
|
+
rollingByColumn(col, spec, mapping) {
|
|
1433
|
+
const columnSpecs = normalizeAggregateColumns(this.schema, mapping);
|
|
1434
|
+
return computeRollingByColumn(this.#store.store, col, spec, columnSpecs);
|
|
1435
|
+
}
|
|
1374
1436
|
groupBy(column, transform) {
|
|
1375
1437
|
const buckets = new Map();
|
|
1376
1438
|
for (const event of this.events) {
|
|
@@ -2688,6 +2750,47 @@ export class TimeSeries {
|
|
|
2688
2750
|
const reshaped = withColumnsRenamed(this.#store.store, mapping);
|
|
2689
2751
|
return TimeSeries.#fromTrustedStore(this.name, resultSchema, reshaped);
|
|
2690
2752
|
}
|
|
2753
|
+
/**
|
|
2754
|
+
* Example:
|
|
2755
|
+
* `series.withColumn('cumDist', cumulativeDistances)` (a `Float64Array`).
|
|
2756
|
+
*
|
|
2757
|
+
* **Attach a computed numeric column.** Returns a new series with `values`
|
|
2758
|
+
* appended as a new `number` column named `name`, so downstream pond ops
|
|
2759
|
+
* (`aggregate`, `byColumn`, `rollingByColumn`, `column(name)`) can reference
|
|
2760
|
+
* it. The existing key + value columns are shared by reference (zero-copy);
|
|
2761
|
+
* only the new column is added. This is the seam that lets a derived array
|
|
2762
|
+
* (cumulative distance, speed, gradient) re-enter the pond pipeline as a real
|
|
2763
|
+
* column instead of staying a side-channel.
|
|
2764
|
+
*
|
|
2765
|
+
* `values` is a `Float64Array` (dense) or a `(number | undefined)[]` (where
|
|
2766
|
+
* `undefined` marks a missing cell), and must have exactly `series.length`
|
|
2767
|
+
* entries. Defined values are validated against the numeric intake contract —
|
|
2768
|
+
* **non-finite (`NaN` / `±Infinity`) is rejected** (a `ValidationError`,
|
|
2769
|
+
* matching construction), so packed numeric columns stay NaN-free; pass
|
|
2770
|
+
* `undefined` for a missing cell, never `NaN`. `name` must not collide with an
|
|
2771
|
+
* existing column.
|
|
2772
|
+
*
|
|
2773
|
+
* This is the *validated* attach. A trusted bulk-construction path
|
|
2774
|
+
* (`fromTrustedColumns`, skipping the finite scan) is a deferred sibling for
|
|
2775
|
+
* when a perf-critical consumer earns it.
|
|
2776
|
+
*/
|
|
2777
|
+
withColumn(name, values) {
|
|
2778
|
+
if (values.length !== this.length) {
|
|
2779
|
+
throw new RangeError(`withColumn: values length ${values.length} does not match series length ${this.length}`);
|
|
2780
|
+
}
|
|
2781
|
+
// Re-assert the numeric intake contract (finite-or-missing) — trusted
|
|
2782
|
+
// construction below bypasses the constructor's strict intake, so a
|
|
2783
|
+
// non-finite cell would otherwise pack into the column and break the
|
|
2784
|
+
// reducer non-finite policy's NaN-free invariant.
|
|
2785
|
+
assertColumnValuesMatchKind('number', values, `withColumn '${String(name)}'`);
|
|
2786
|
+
const column = columnFromValuesByKind('number', values);
|
|
2787
|
+
const reshaped = withColumnAppended(this.#store.store, name, column);
|
|
2788
|
+
const resultSchema = Object.freeze([
|
|
2789
|
+
...this.schema,
|
|
2790
|
+
{ name, kind: 'number' },
|
|
2791
|
+
]);
|
|
2792
|
+
return TimeSeries.#fromTrustedStore(this.name, resultSchema, reshaped);
|
|
2793
|
+
}
|
|
2691
2794
|
collapse(keys, output, reducer, options) {
|
|
2692
2795
|
// Column-native (Step 4): the reducer runs over the keyed columns read
|
|
2693
2796
|
// straight off the store in the extracted `collapseOp` — no
|