pond-ts 0.26.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 +18 -2
- package/dist/batch/by-column.d.ts +29 -0
- package/dist/batch/by-column.js +166 -0
- package/dist/batch/time-series.d.ts +32 -0
- package/dist/batch/time-series.js +32 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -7,7 +7,8 @@ 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
|
|
11
12
|
[0.26.0]: https://github.com/pjm17971/pond-ts/compare/v0.25.0...v0.26.0
|
|
12
13
|
[0.25.0]: https://github.com/pjm17971/pond-ts/compare/v0.24.0...v0.25.0
|
|
13
14
|
[0.24.0]: https://github.com/pjm17971/pond-ts/compare/v0.23.0...v0.24.0
|
|
@@ -20,7 +21,22 @@ type-level changes; patch bumps are strictly additive.
|
|
|
20
21
|
|
|
21
22
|
## [Unreleased]
|
|
22
23
|
|
|
23
|
-
## [0.
|
|
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`.
|
|
24
40
|
|
|
25
41
|
### Changed
|
|
26
42
|
|
|
@@ -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
|
|
@@ -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.
|
|
@@ -6,6 +6,7 @@ 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';
|
|
9
10
|
import { BoundedSequence } from '../sequence/bounded-sequence.js';
|
|
10
11
|
import { Interval } from '../core/interval.js';
|
|
11
12
|
import { Time } from '../core/time.js';
|
|
@@ -1371,6 +1372,37 @@ export class TimeSeries {
|
|
|
1371
1372
|
}
|
|
1372
1373
|
return result;
|
|
1373
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
|
+
}
|
|
1374
1406
|
groupBy(column, transform) {
|
|
1375
1407
|
const buckets = new Map();
|
|
1376
1408
|
for (const event of this.events) {
|