pond-ts 0.29.0 → 0.30.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 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.29.0...HEAD
10
+ [Unreleased]: https://github.com/pjm17971/pond-ts/compare/v0.30.0...HEAD
11
+ [0.30.0]: https://github.com/pjm17971/pond-ts/compare/v0.29.0...v0.30.0
11
12
  [0.29.0]: https://github.com/pjm17971/pond-ts/compare/v0.28.0...v0.29.0
12
13
  [0.28.0]: https://github.com/pjm17971/pond-ts/compare/v0.27.0...v0.28.0
13
14
  [0.27.0]: https://github.com/pjm17971/pond-ts/compare/v0.26.0...v0.27.0
@@ -21,7 +22,36 @@ type-level changes; patch bumps are strictly additive.
21
22
  [0.19.0]: https://github.com/pjm17971/pond-ts/compare/v0.18.0...v0.19.0
22
23
  [0.18.0]: https://github.com/pjm17971/pond-ts/compare/v0.17.1...v0.18.0
23
24
 
24
- ## [Unreleased]
25
+ ## [0.30.0] — 2026-06-17
26
+
27
+ ### Added
28
+
29
+ - **`rollingByColumn(col, { radius, at }, mapping)` — evaluate at explicit
30
+ centers.** `at` takes a **non-decreasing** array of center values (e.g. a chart's
31
+ coarse display grid) and returns **one record per center**, instead of the
32
+ default one-per-row. A center with no rows within `±radius` yields each
33
+ reducer's empty value. Same O(n + centers) two-pointer. Closes the
34
+ evaluate-at-grid gap surfaced adopting `rollingByColumn` for a chart variance
35
+ band. (estela F-rolling-by-row.)
36
+ - **`smooth(col, 'movingAverage' | 'loess', { …, missing: 'skip' })` —
37
+ validity-respecting smoothing.** By default (`missing: 'bridge'`) a cell whose
38
+ own value is missing is still assigned a smoothed value from its present
39
+ neighbours — the line is drawn _across_ the hole. `missing: 'skip'` keeps a
40
+ missing cell **missing** in the output, so a sustained dropout (a coast, a
41
+ sensor gap) is preserved as a break rather than fabricated through. Present
42
+ cells smooth over only the present values in their window either way. `ema`
43
+ takes no `missing` option (it is causal and never fabricates across a gap). A
44
+ `maxGap` hard segment boundary is a deferred follow-on. (estela
45
+ F-smooth-interactive.)
46
+
47
+ ### Changed
48
+
49
+ - **`byColumn(…, { inclusive: '(]' })` floor edge is now inclusive.** Under
50
+ `'(]'`, interior bins stay upper-inclusive (`(eᵢ, eᵢ₊₁]`) but the **floor `e₀`
51
+ is inclusive** (bin 0 is `[e₀, e₁]`), so a value at exactly the minimum edge —
52
+ e.g. a `0` W coast/stop sample at a zone floor of 0 — lands in bin 0 instead of
53
+ being dropped (the `include_lowest` convention). Previously the floor was
54
+ exclusive. (estela F-inclusive-floor.)
25
55
 
26
56
  ## [0.29.0] — 2026-06-17
27
57
 
@@ -47,17 +47,21 @@ export function computeByColumn(store, binColName, spec, columnSpecs) {
47
47
  const last = edges.length - 1;
48
48
  // `inclusive` picks the bin a value exactly on an interior edge falls into.
49
49
  // `'[)'` (default): bins are `[eᵢ, eᵢ₊₁)`, lower-inclusive — a boundary value
50
- // goes to the bin ABOVE; range is `[e₀, eₙ)`. `'(]'`: bins are `(eᵢ, eᵢ₊₁]`,
51
- // upper-inclusive (Coggan power/HR zones — a sample at exactly a zone's top
52
- // edge is the LOWER zone) — a boundary value goes to the bin BELOW; range is
53
- // `(e₀, eₙ]`, so the first edge is an exclusive floor (set it below your
54
- // minimum to keep the minimum in bin 0).
50
+ // goes to the bin ABOVE; range is `[e₀, eₙ)`. `'(]'`: interior bins are
51
+ // `(eᵢ, eᵢ₊₁]`, upper-inclusive (Coggan power/HR zones — a sample at exactly
52
+ // a zone's top edge is the LOWER zone) — a boundary value goes to the bin
53
+ // BELOW. The **floor `e₀` is inclusive** (bin 0 is `[e₀, e₁]`), so a value at
54
+ // exactly the minimum edge e.g. a `0` W coast/stop sample — lands in bin 0
55
+ // rather than being dropped (the `include_lowest` convention; estela
56
+ // F-inclusive-floor). Range is `[e₀, eₙ]`.
55
57
  binOf =
56
58
  spec.inclusive === '(]'
57
59
  ? (v) => {
58
- if (v <= edges[0] || v > edges[last])
59
- return NaN; // out of (e₀, eₙ]
60
- // rightmost edge STRICTLY less than v, clamped to a valid bin [0, last)
60
+ if (v < edges[0] || v > edges[last])
61
+ return NaN; // out of [e₀, eₙ]
62
+ // rightmost edge STRICTLY less than v (so an interior edge goes to
63
+ // the bin below), clamped to [0, last); v === e₀ → bin 0 (floor
64
+ // inclusive).
61
65
  let lo = 0;
62
66
  let hi = last;
63
67
  while (lo < hi) {
@@ -3,12 +3,19 @@ import type { ColumnValue } from '../schema/index.js';
3
3
  import type { AggregateColumnSpec } from './aggregate-columns.js';
4
4
  /**
5
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
6
+ * half-width `radius`, in the axis column's own units.
7
+ *
8
+ * By default a window is evaluated at **every input row** (one record per row).
9
+ * Pass `at` — a **non-decreasing** array of explicit center values (e.g. a chart's
10
+ * coarse display grid) — to evaluate at those centers instead, returning one
11
+ * record per center. Each center's window is the rows whose axis value lies
12
+ * within `±radius` of it; a center need not coincide with any row, and a center
13
+ * with no rows in range yields each reducer's empty value. See
8
14
  * `docs/notes/rolling-by-column.md`.
9
15
  */
10
16
  export type WindowSpec = {
11
17
  radius: number;
18
+ at?: readonly number[];
12
19
  };
13
20
  /** One windowed record: the mapped aggregates over the window centered at a row. */
14
21
  export type WindowRecord = Record<string, ColumnValue | undefined>;
@@ -55,6 +55,50 @@ export function computeRollingByColumn(store, axisColName, spec, columnSpecs) {
55
55
  // the whole sweep — this is what makes it O(n) rather than O(n · window).
56
56
  const states = columnSpecs.map((s) => rollingStateFor(s.reducer));
57
57
  const specCount = columnSpecs.length;
58
+ // `at` mode (estela F-rolling-by-row): evaluate at explicit ascending centers
59
+ // (e.g. a chart's display grid) rather than at every input row. One record per
60
+ // center; the two-pointer sweeps the same shared states across the centers, so
61
+ // it stays O(n + at.length). A center with no rows in `±radius` empties the
62
+ // window and yields each reducer's empty value.
63
+ if (spec.at !== undefined) {
64
+ const at = spec.at;
65
+ for (let k = 0; k < at.length; k += 1) {
66
+ if (!Number.isFinite(at[k])) {
67
+ throw new RangeError(`rollingByColumn: at[${k}] is not finite`);
68
+ }
69
+ if (k > 0 && at[k] < at[k - 1]) {
70
+ throw new RangeError('rollingByColumn: at centers must be non-decreasing');
71
+ }
72
+ }
73
+ const atOut = new Array(at.length);
74
+ let alo = 0;
75
+ let ahi = 0; // window holds compact positions [alo, ahi)
76
+ for (let k = 0; k < at.length; k += 1) {
77
+ const c = at[k];
78
+ const wlo = c - radius;
79
+ const whi = c + radius;
80
+ while (ahi < m && ax[ahi] <= whi) {
81
+ const r = idx[ahi];
82
+ for (let cc = 0; cc < specCount; cc += 1) {
83
+ states[cc].add(r, sourceCols[cc].read(r));
84
+ }
85
+ ahi += 1;
86
+ }
87
+ while (alo < ahi && ax[alo] < wlo) {
88
+ const r = idx[alo];
89
+ for (let cc = 0; cc < specCount; cc += 1) {
90
+ states[cc].remove(r, sourceCols[cc].read(r));
91
+ }
92
+ alo += 1;
93
+ }
94
+ const rec = {};
95
+ for (let cc = 0; cc < specCount; cc += 1) {
96
+ rec[columnSpecs[cc].output] = states[cc].snapshot();
97
+ }
98
+ atOut[k] = rec;
99
+ }
100
+ return atOut;
101
+ }
58
102
  const out = new Array(n);
59
103
  let lo = 0;
60
104
  let hi = 0; // the window currently holds compact positions [lo, hi)
@@ -541,9 +541,11 @@ export declare class TimeSeries<S extends SeriesSchema> {
541
541
  * yields a histogram (distribution).
542
542
  * - `{ edges, inclusive? }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins.
543
543
  * `inclusive` defaults to `'[)'` (bin `i` = `[eᵢ, eᵢ₊₁)`, lower-inclusive);
544
- * pass `'(]'` for upper-inclusive bins (`(eᵢ, eᵢ₊₁]`) — Coggan power / HR
545
- * zones, where a sample exactly on a zone's top edge belongs to the lower
546
- * zone (the first edge becomes an exclusive floor). Always emits all `n` bins.
544
+ * pass `'(]'` for upper-inclusive interior bins (`(eᵢ, eᵢ₊₁]`) — Coggan power
545
+ * / HR zones, where a sample exactly on a zone's top edge belongs to the
546
+ * lower zone. The floor `e₀` stays **inclusive** (bin 0 is `[e₀, e₁]`), so a
547
+ * minimum-edge value (e.g. a `0` W coast sample) lands in bin 0 rather than
548
+ * being dropped. Always emits all `n` bins.
547
549
  *
548
550
  * A row whose bin value is missing / non-finite (or, for `edges`, outside
549
551
  * `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
@@ -577,7 +579,12 @@ export declare class TimeSeries<S extends SeriesSchema> {
577
579
  * reducer non-finite policy still applies to the *source* columns.
578
580
  *
579
581
  * The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
580
- * radius`); a single O(n) two-pointer sweep maintains the window. See
582
+ * radius`); a single O(n) two-pointer sweep maintains the window.
583
+ *
584
+ * Pass `{ radius, at }` — a **non-decreasing** array of explicit center values
585
+ * (e.g. a chart's coarse display grid) — to evaluate at those centers instead
586
+ * of at every row, returning **one record per center** (a center with no rows
587
+ * in range yields each reducer's empty value). See
581
588
  * `docs/notes/rolling-by-column.md`.
582
589
  */
583
590
  rollingByColumn<const Mapping extends ValidatedAggregateMap<S, Mapping>>(col: NumericColumnNameForSchema<S>, spec: WindowSpec, mapping: Mapping): Array<ReduceResult<S, Mapping>>;
@@ -998,6 +1005,17 @@ export declare class TimeSeries<S extends SeriesSchema> {
998
1005
  * When `output` is omitted, the smoothed values replace the target column. When `output` is
999
1006
  * supplied, the smoothed values are appended as a new optional numeric column.
1000
1007
  *
1008
+ * **Missing cells** (`movingAverage` / `loess`): by default (`missing:
1009
+ * 'bridge'`) a cell whose own value is missing is still assigned a smoothed
1010
+ * value computed from its present neighbours — i.e. the line is drawn *across*
1011
+ * the hole. Pass `missing: 'skip'` to keep a missing cell **missing** in the
1012
+ * output, so a sustained dropout (a coast, a sensor gap) is preserved as a
1013
+ * break rather than fabricated through. Present cells already smooth over only
1014
+ * the present values in their window either way. `ema` is causal and never
1015
+ * fabricates across a gap, so it takes no `missing` option. (estela
1016
+ * F-smooth-interactive; a `maxGap` hard segment boundary is a deferred
1017
+ * follow-on.)
1018
+ *
1001
1019
  * **Multi-entity series:** the smoothing window pulls values from
1002
1020
  * every entity into each smoothed point — `host-A`'s smoothed value
1003
1021
  * is blended with `host-B`'s and `host-C`'s. On a series carrying
@@ -1013,9 +1031,11 @@ export declare class TimeSeries<S extends SeriesSchema> {
1013
1031
  window: DurationInput;
1014
1032
  alignment?: RollingAlignment;
1015
1033
  output?: Output;
1034
+ missing?: 'skip' | 'bridge';
1016
1035
  } | {
1017
1036
  span: number;
1018
1037
  output?: Output;
1038
+ missing?: 'skip' | 'bridge';
1019
1039
  }): TimeSeries<Output extends string ? SmoothAppendSchema<S, Output> : SmoothSchema<S, Target>>;
1020
1040
  /** Example: `series.slice(0, 10)`. Returns a positional half-open slice of the series. */
1021
1041
  slice(beginIndex?: number, endIndex?: number): TimeSeries<S>;
@@ -1397,9 +1397,11 @@ export class TimeSeries {
1397
1397
  * yields a histogram (distribution).
1398
1398
  * - `{ edges, inclusive? }` — explicit ascending edges `[e₀ … eₙ]` → `n` bins.
1399
1399
  * `inclusive` defaults to `'[)'` (bin `i` = `[eᵢ, eᵢ₊₁)`, lower-inclusive);
1400
- * pass `'(]'` for upper-inclusive bins (`(eᵢ, eᵢ₊₁]`) — Coggan power / HR
1401
- * zones, where a sample exactly on a zone's top edge belongs to the lower
1402
- * zone (the first edge becomes an exclusive floor). Always emits all `n` bins.
1400
+ * pass `'(]'` for upper-inclusive interior bins (`(eᵢ, eᵢ₊₁]`) — Coggan power
1401
+ * / HR zones, where a sample exactly on a zone's top edge belongs to the
1402
+ * lower zone. The floor `e₀` stays **inclusive** (bin 0 is `[e₀, e₁]`), so a
1403
+ * minimum-edge value (e.g. a `0` W coast sample) lands in bin 0 rather than
1404
+ * being dropped. Always emits all `n` bins.
1403
1405
  *
1404
1406
  * A row whose bin value is missing / non-finite (or, for `edges`, outside
1405
1407
  * `[e₀, eₙ)`) contributes to no bin. The reducer non-finite policy still
@@ -1433,7 +1435,12 @@ export class TimeSeries {
1433
1435
  * reducer non-finite policy still applies to the *source* columns.
1434
1436
  *
1435
1437
  * The window is centered and inclusive (`col[i] − radius ≤ col[j] ≤ col[i] +
1436
- * radius`); a single O(n) two-pointer sweep maintains the window. See
1438
+ * radius`); a single O(n) two-pointer sweep maintains the window.
1439
+ *
1440
+ * Pass `{ radius, at }` — a **non-decreasing** array of explicit center values
1441
+ * (e.g. a chart's coarse display grid) — to evaluate at those centers instead
1442
+ * of at every row, returning **one record per center** (a center with no rows
1443
+ * in range yields each reducer's empty value). See
1437
1444
  * `docs/notes/rolling-by-column.md`.
1438
1445
  */
1439
1446
  rollingByColumn(col, spec, mapping) {
@@ -2211,6 +2218,17 @@ export class TimeSeries {
2211
2218
  * When `output` is omitted, the smoothed values replace the target column. When `output` is
2212
2219
  * supplied, the smoothed values are appended as a new optional numeric column.
2213
2220
  *
2221
+ * **Missing cells** (`movingAverage` / `loess`): by default (`missing:
2222
+ * 'bridge'`) a cell whose own value is missing is still assigned a smoothed
2223
+ * value computed from its present neighbours — i.e. the line is drawn *across*
2224
+ * the hole. Pass `missing: 'skip'` to keep a missing cell **missing** in the
2225
+ * output, so a sustained dropout (a coast, a sensor gap) is preserved as a
2226
+ * break rather than fabricated through. Present cells already smooth over only
2227
+ * the present values in their window either way. `ema` is causal and never
2228
+ * fabricates across a gap, so it takes no `missing` option. (estela
2229
+ * F-smooth-interactive; a `maxGap` hard segment boundary is a deferred
2230
+ * follow-on.)
2231
+ *
2214
2232
  * **Multi-entity series:** the smoothing window pulls values from
2215
2233
  * every entity into each smoothed point — `host-A`'s smoothed value
2216
2234
  * is blended with `host-B`'s and `host-C`'s. On a series carrying
@@ -2297,8 +2315,14 @@ export class TimeSeries {
2297
2315
  loessValues.push(value);
2298
2316
  }
2299
2317
  }
2318
+ // `missing: 'skip'` — a cell whose own value is missing stays missing
2319
+ // (don't fit a fabricated value across the hole from its present
2320
+ // neighbours). Default `'bridge'` is the prior behaviour (fit everywhere).
2321
+ const skipMissing = options.missing === 'skip';
2300
2322
  const resultRows = this.events.map((event, index) => {
2301
- const smoothed = loessAt(anchors[index], loessAnchors, loessValues, span);
2323
+ const smoothed = skipMissing && sourceValues[index] === undefined
2324
+ ? undefined
2325
+ : loessAt(anchors[index], loessAnchors, loessValues, span);
2302
2326
  const nextEvent = output === undefined
2303
2327
  ? event.set(column, smoothed)
2304
2328
  : event.merge({ [output]: smoothed });
@@ -2321,6 +2345,7 @@ export class TimeSeries {
2321
2345
  const window = options.window;
2322
2346
  const windowMs = parseDuration(window);
2323
2347
  const alignment = options.alignment ?? 'trailing';
2348
+ const skipMissing = options.missing === 'skip';
2324
2349
  const resultValues = new Array(this.events.length);
2325
2350
  let windowStart = 0;
2326
2351
  let windowEnd = 0;
@@ -2387,7 +2412,12 @@ export class TimeSeries {
2387
2412
  }
2388
2413
  const smoothed = snapshot();
2389
2414
  for (let index = groupStart; index < groupEnd; index++) {
2390
- resultValues[index] = smoothed;
2415
+ // `missing: 'skip'` — a cell whose own value is missing stays missing
2416
+ // (don't fabricate it from the window average). Default `'bridge'` fills.
2417
+ resultValues[index] =
2418
+ skipMissing && sourceValues[index] === undefined
2419
+ ? undefined
2420
+ : smoothed;
2391
2421
  }
2392
2422
  groupStart = groupEnd;
2393
2423
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pond-ts",
3
- "version": "0.29.0",
3
+ "version": "0.30.0",
4
4
  "description": "TypeScript-first time series primitives",
5
5
  "license": "MIT",
6
6
  "repository": {