@winkjs/composer 0.4.2 → 0.5.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/README.md +5 -1
- package/package.json +1 -1
- package/src/composer.js +9 -0
- package/src/tools/stream-prep/coerce-numeric.js +94 -0
- package/src/tools/stream-prep/filter-rows.js +96 -0
- package/src/tools/stream-prep/label-shift.js +112 -0
- package/src/tools/stream-prep/normalize-timestamp.js +265 -0
- package/src/tools/stream-prep/stamp-period.js +111 -0
- package/src/tools/stream-prep/track-activity.js +223 -0
- package/src/tools/stream-prep/validate.js +76 -0
package/README.md
CHANGED
|
@@ -109,11 +109,15 @@ The numbers below come from a pure compute benchmark — every step a live messa
|
|
|
109
109
|
| Configuration | Throughput |
|
|
110
110
|
|----------------------|-------------------------|
|
|
111
111
|
| Raspberry Pi 5 | ~100K messages/second |
|
|
112
|
-
|
|
|
112
|
+
| MacBook Pro (M4 Max) | ~1.1M messages/second |
|
|
113
113
|
| Tracking 10K assets | ~500K messages/second |
|
|
114
114
|
|
|
115
|
+
Both non-Pi rows were measured on a MacBook Pro (M4 Max) under Node.js 22. Every figure is single-core.
|
|
116
|
+
|
|
115
117
|
Two configurations produced these numbers. The first two rows interleave 10 asset pipelines in random order — 4.5 million messages (10 pipelines × 900 data points × 500 iterations). The third row runs the same flow with 10,000 assets — 10,000 isolated states alive at once, 9 million messages, under 140 MB of heap. Pipelines are created dynamically as each asset first appears; timing uses `process.hrtime.bigint()`. The same pipeline [runs in your browser](https://composer.winkjs.org/docs/benchmark) — browser performance varies from native Node.js due to JIT differences.
|
|
116
118
|
|
|
119
|
+
Reproduce them from a repo clone. `node benchmark/compare.js 10 500` reports the median of three rounds. `node benchmark/run-benchmark.js static 10000 1` gives the 10K-asset row.
|
|
120
|
+
|
|
117
121
|
|
|
118
122
|
## Built for the Real World
|
|
119
123
|
|
package/package.json
CHANGED
package/src/composer.js
CHANGED
|
@@ -73,3 +73,12 @@ export {
|
|
|
73
73
|
export { sigmoid, train, predict, classify } from './tools/training/logistic.js';
|
|
74
74
|
export { confusionMatrix, classificationMetrics, sweepThresholds } from './tools/training/metrics.js';
|
|
75
75
|
|
|
76
|
+
// Stream-preparation utilities — ready-made functions for a source's
|
|
77
|
+
// `transform` option (see docs/handbook/stream-preparation.md)
|
|
78
|
+
export { coerceNumeric, coerceCell } from './tools/stream-prep/coerce-numeric.js';
|
|
79
|
+
export { normalizeTimestamp } from './tools/stream-prep/normalize-timestamp.js';
|
|
80
|
+
export { filterRows } from './tools/stream-prep/filter-rows.js';
|
|
81
|
+
export { labelShift, shiftLabelFor } from './tools/stream-prep/label-shift.js';
|
|
82
|
+
export { trackActivity } from './tools/stream-prep/track-activity.js';
|
|
83
|
+
export { stampPeriod } from './tools/stream-prep/stamp-period.js';
|
|
84
|
+
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// src/tools/stream-prep/coerce-numeric.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview coerceNumeric — a stream-preparation utility for a source's
|
|
5
|
+
* `transform` option. Given a fixed list of fields, it rewrites each field on
|
|
6
|
+
* the message to a clean finite number or NaN, in place, allocating nothing
|
|
7
|
+
* per message.
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: raw feeds (CSV replay, MQTT, a historian export) deliver
|
|
10
|
+
* blanks, empty strings, and out-of-range collector garbage mixed with real
|
|
11
|
+
* numbers. A downstream node's NaN-skip only works if "no value" reads as
|
|
12
|
+
* NaN, never 0 — and `Number( '' )` is 0 and `Number( null )` is 0, so a
|
|
13
|
+
* naive coercion silently invents zeros where data is missing. This maps
|
|
14
|
+
* every not-a-real-number to NaN explicitly, so a blank stays a blank.
|
|
15
|
+
*
|
|
16
|
+
* Scope is deliberately narrow and field-agnostic: it does NOT know any
|
|
17
|
+
* field's physical range. Field-specific validity (a -9999 code, a bath
|
|
18
|
+
* below 5 degC) is the `sanitize` node's job in the flow (the ADR-018
|
|
19
|
+
* boundary: a bad value in a parseable record is the pipeline's concern).
|
|
20
|
+
* This utility only guarantees the value is a finite number or NaN, plus an
|
|
21
|
+
* optional magnitude cut (`sentinelAbs`) for the huge collector garbage
|
|
22
|
+
* (e.g. 3.4e38) that is invalid for every field.
|
|
23
|
+
*
|
|
24
|
+
* Family contract (ADR-025, stream-preparation utilities): configuration is
|
|
25
|
+
* captured once at init; the returned function mutates the message in place,
|
|
26
|
+
* creates no objects per message, and returns the same reference, so it
|
|
27
|
+
* drops straight into a source `transform` slot ( row -> row ).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Coerce one raw cell to a number or NaN, without allocating. Empty string and
|
|
32
|
+
* null/undefined are "no value" -> NaN (never 0). A numeric string parses; a
|
|
33
|
+
* boolean maps to 1/0; anything unparseable is NaN.
|
|
34
|
+
*
|
|
35
|
+
* Known limitation: a whitespace-only string ( ' ' ) coerces to 0, matching
|
|
36
|
+
* `Number`'s own behaviour — detecting it would cost a per-cell allocation, and
|
|
37
|
+
* real feeds use '' or null for a missing cell. Add such tokens upstream if a
|
|
38
|
+
* feed genuinely encodes missing as whitespace.
|
|
39
|
+
*
|
|
40
|
+
* @param {*} value - the raw cell
|
|
41
|
+
* @returns {number} a number, possibly NaN
|
|
42
|
+
*/
|
|
43
|
+
const coerceCell = function ( value ) {
|
|
44
|
+
if ( typeof value === 'number' ) {
|
|
45
|
+
return value;
|
|
46
|
+
}
|
|
47
|
+
if ( ( value === null ) || ( value === undefined ) || ( value === '' ) ) {
|
|
48
|
+
return NaN;
|
|
49
|
+
}
|
|
50
|
+
return Number( value );
|
|
51
|
+
}; // coerceCell()
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Build an in-place numeric coercer for a fixed set of fields.
|
|
55
|
+
*
|
|
56
|
+
* @param {string[]} fields - message fields to coerce (non-empty array of
|
|
57
|
+
* non-empty strings; validated at init, fail-fast)
|
|
58
|
+
* @param {Object} [options]
|
|
59
|
+
* @param {number} [options.sentinelAbs] - if set, any coerced value whose
|
|
60
|
+
* magnitude is at or above this becomes NaN. A cut for universally-invalid
|
|
61
|
+
* collector garbage, NOT a per-field physical range (that lives in sanitize).
|
|
62
|
+
* @returns {function( Object ): Object} transform( msg ) -> msg, mutated in place
|
|
63
|
+
*/
|
|
64
|
+
const coerceNumeric = function ( fields, options ) {
|
|
65
|
+
if ( !Array.isArray( fields ) || ( fields.length === 0 ) ) {
|
|
66
|
+
throw new Error( 'winkComposer/coerceNumeric: fields must be a non-empty array.' );
|
|
67
|
+
}
|
|
68
|
+
for ( let i = 0; i < fields.length; i += 1 ) {
|
|
69
|
+
if ( ( typeof fields[ i ] !== 'string' ) || ( fields[ i ] === '' ) ) {
|
|
70
|
+
throw new Error( 'winkComposer/coerceNumeric: every field must be a non-empty string.' );
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
const opts = options || {};
|
|
74
|
+
const sentinelAbs = ( opts.sentinelAbs === undefined ) ? Infinity : opts.sentinelAbs;
|
|
75
|
+
if ( ( typeof sentinelAbs !== 'number' ) || Number.isNaN( sentinelAbs ) || ( sentinelAbs <= 0 ) ) {
|
|
76
|
+
throw new Error( 'winkComposer/coerceNumeric: sentinelAbs must be a positive number.' );
|
|
77
|
+
}
|
|
78
|
+
// Private copy so the caller cannot mutate the field list after init; index
|
|
79
|
+
// iteration over a stable local keeps the hot path free of iterator objects.
|
|
80
|
+
const fieldList = fields.slice();
|
|
81
|
+
const n = fieldList.length;
|
|
82
|
+
|
|
83
|
+
return function ( msg ) {
|
|
84
|
+
for ( let i = 0; i < n; i += 1 ) {
|
|
85
|
+
const field = fieldList[ i ];
|
|
86
|
+
const num = coerceCell( msg[ field ] );
|
|
87
|
+
msg[ field ] = ( Number.isFinite( num ) && ( Math.abs( num ) < sentinelAbs ) ) ? num : NaN;
|
|
88
|
+
}
|
|
89
|
+
return msg;
|
|
90
|
+
};
|
|
91
|
+
}; // coerceNumeric()
|
|
92
|
+
|
|
93
|
+
export { coerceCell, coerceNumeric };
|
|
94
|
+
export default coerceNumeric;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// src/tools/stream-prep/filter-rows.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview filterRows — a stream-preparation utility for a source's
|
|
5
|
+
* `transform` option. Keeps only the rows whose timestamp falls inside an
|
|
6
|
+
* inclusive time window, by returning null for everything outside it. The
|
|
7
|
+
* source counts each null as a skipped record, so drops stay visible.
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: replay work — demos, evaluations, byte-identity reruns —
|
|
10
|
+
* almost always studies a window of a longer recording. Field flows carried
|
|
11
|
+
* the same two-compare window filter as inline code many times over; this is
|
|
12
|
+
* that filter, once. A live production feed normally has no use for it.
|
|
13
|
+
*
|
|
14
|
+
* The bounds are a Knob of the run, fixed at init: `from` and `to` accept an
|
|
15
|
+
* epoch-ms number or an ISO-8601 string (parsed once, at init — never per
|
|
16
|
+
* row). The hot path is two number compares against the timestamp field.
|
|
17
|
+
* Run it AFTER `normalizeTimestamp`, so the field already holds epoch ms.
|
|
18
|
+
*
|
|
19
|
+
* A row whose timestamp is not a finite number cannot be placed inside or
|
|
20
|
+
* outside the window, so it is dropped too. Rows with a bad clock never
|
|
21
|
+
* reach the flow through this filter.
|
|
22
|
+
*
|
|
23
|
+
* Deliberately NOT here: arbitrary predicates. The `transform` hook itself
|
|
24
|
+
* is already "return null to drop", so a custom predicate needs no utility.
|
|
25
|
+
* This one earns its place only by owning the bounds parsing and the
|
|
26
|
+
* inclusive-window rule.
|
|
27
|
+
*
|
|
28
|
+
* Family contract (ADR-025, stream-preparation utilities): config captured
|
|
29
|
+
* at init; zero per-row allocation; returns the same message reference, or
|
|
30
|
+
* null to drop ( row -> row | null ).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Resolve one bound to epoch ms at init. Numbers must be finite; strings go
|
|
35
|
+
* through `Date.parse` once. Anything else, or an unparseable string, throws.
|
|
36
|
+
*
|
|
37
|
+
* @param {*} value - the bound as given
|
|
38
|
+
* @param {string} name - option name for the error message
|
|
39
|
+
* @returns {number} epoch ms
|
|
40
|
+
*/
|
|
41
|
+
const resolveBound = function ( value, name ) {
|
|
42
|
+
if ( typeof value === 'number' ) {
|
|
43
|
+
if ( !Number.isFinite( value ) ) {
|
|
44
|
+
throw new Error( 'winkComposer/filterRows: ' + name + ' must be a finite epoch-ms number or a parseable date string.' );
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
if ( typeof value === 'string' ) {
|
|
49
|
+
const parsed = Date.parse( value );
|
|
50
|
+
if ( !Number.isFinite( parsed ) ) {
|
|
51
|
+
throw new Error( 'winkComposer/filterRows: ' + name + ' is not a parseable date string (got \'' + value + '\').' );
|
|
52
|
+
}
|
|
53
|
+
return parsed;
|
|
54
|
+
}
|
|
55
|
+
throw new Error( 'winkComposer/filterRows: ' + name + ' must be a finite epoch-ms number or a parseable date string.' );
|
|
56
|
+
}; // resolveBound()
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Build an inclusive time-window row filter.
|
|
60
|
+
*
|
|
61
|
+
* @param {Object} options
|
|
62
|
+
* @param {string} [options.field='timestamp'] - field holding epoch ms
|
|
63
|
+
* (normalize it first — see normalizeTimestamp).
|
|
64
|
+
* @param {number|string} [options.from] - inclusive lower bound (epoch ms or
|
|
65
|
+
* ISO-8601 string). At least one of from/to is required.
|
|
66
|
+
* @param {number|string} [options.to] - inclusive upper bound (epoch ms or
|
|
67
|
+
* ISO-8601 string).
|
|
68
|
+
* @returns {function( Object ): (Object|null)} transform( msg ) -> msg inside
|
|
69
|
+
* the window, null outside it. A non-finite timestamp also returns null.
|
|
70
|
+
*/
|
|
71
|
+
const filterRows = function ( options ) {
|
|
72
|
+
const opts = options || {};
|
|
73
|
+
const field = ( opts.field === undefined ) ? 'timestamp' : opts.field;
|
|
74
|
+
if ( ( typeof field !== 'string' ) || ( field === '' ) ) {
|
|
75
|
+
throw new Error( 'winkComposer/filterRows: field must be a non-empty string.' );
|
|
76
|
+
}
|
|
77
|
+
if ( ( opts.from === undefined ) && ( opts.to === undefined ) ) {
|
|
78
|
+
throw new Error( 'winkComposer/filterRows: at least one of from/to is required.' );
|
|
79
|
+
}
|
|
80
|
+
const fromMs = ( opts.from === undefined ) ? -Infinity : resolveBound( opts.from, 'from' );
|
|
81
|
+
const toMs = ( opts.to === undefined ) ? Infinity : resolveBound( opts.to, 'to' );
|
|
82
|
+
if ( fromMs > toMs ) {
|
|
83
|
+
throw new Error( 'winkComposer/filterRows: from must not be later than to.' );
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return function ( msg ) {
|
|
87
|
+
const t = msg[ field ];
|
|
88
|
+
if ( !Number.isFinite( t ) ) {
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
return ( ( t >= fromMs ) && ( t <= toMs ) ) ? msg : null;
|
|
92
|
+
};
|
|
93
|
+
}; // filterRows()
|
|
94
|
+
|
|
95
|
+
export { filterRows };
|
|
96
|
+
export default filterRows;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
// src/tools/stream-prep/label-shift.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview labelShift — a stream-preparation utility for a source's
|
|
5
|
+
* `transform` option. Stamps each message with the production shift it falls
|
|
6
|
+
* in (e.g. S1 / S2 / S3), derived from the event timestamp, in place, with no
|
|
7
|
+
* per-message allocation.
|
|
8
|
+
*
|
|
9
|
+
* The shift schedule is a deployment Knob, not a universal fact — sites
|
|
10
|
+
* differ in shift count, length, start time, and labels. So everything is
|
|
11
|
+
* config: give it the schedule, it labels any feed.
|
|
12
|
+
*
|
|
13
|
+
* Why alloc-free matters and how: the obvious implementation, `new Date( ms )
|
|
14
|
+
* .getUTCHours()`, allocates a Date object every message — unacceptable on
|
|
15
|
+
* the hot path. Instead this reduces the epoch to a minute-of-day by integer
|
|
16
|
+
* modulo and picks the shift by a short scan over the boundary list. All
|
|
17
|
+
* configuration (offset, boundaries, labels) is captured once at init; the
|
|
18
|
+
* returned function reads/writes the message in place, creates no objects,
|
|
19
|
+
* and returns the same message so it drops into a source `transform` slot
|
|
20
|
+
* ( row -> row ) — the ADR-025 family contract.
|
|
21
|
+
*
|
|
22
|
+
* Local time is a FIXED offset from UTC. Daylight-saving sites are out of
|
|
23
|
+
* scope: a fixed offset cannot follow a DST change, and the timezone
|
|
24
|
+
* machinery that could would allocate per message. India (IST, +5:30)
|
|
25
|
+
* observes no DST; a DST site must resolve local time upstream.
|
|
26
|
+
*
|
|
27
|
+
* The shift schedule is given as `boundariesMin` — the minute-of-day at which
|
|
28
|
+
* each shift starts, ascending — and matching `labels`. A time earlier than
|
|
29
|
+
* the first boundary belongs to the last shift (it wrapped past midnight), so
|
|
30
|
+
* a schedule whose first shift starts at 06:00 labels the small hours
|
|
31
|
+
* correctly.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
import { fieldNameOr, finiteNumberOr, validatedBoundaries } from './validate.js';
|
|
35
|
+
|
|
36
|
+
const MS_PER_MIN = 60000;
|
|
37
|
+
const MS_PER_DAY = 86400000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Pure shift lookup: which shift an epoch-ms instant falls in. Reduces the instant to
|
|
41
|
+
* a local minute-of-day (fixed offset, integer modulo — no Date object) and returns
|
|
42
|
+
* the label of the last boundary at or below it; a time before the first boundary
|
|
43
|
+
* wraps to the last shift. Shared by the hot-path labeler below and by any caller that
|
|
44
|
+
* needs the shift of a one-off instant (e.g. the shift that just ended at a boundary).
|
|
45
|
+
*
|
|
46
|
+
* @param {number} t - epoch ms; a non-finite value returns null (no shift known).
|
|
47
|
+
* @param {number} offsetMinutes - minutes to add to reach local wall-clock (IST = 330).
|
|
48
|
+
* @param {number[]} boundariesMin - shift start minutes-of-day, ascending.
|
|
49
|
+
* @param {string[]} labels - one label per boundary.
|
|
50
|
+
* @returns {string|null} the shift label, or null for a non-finite instant.
|
|
51
|
+
*/
|
|
52
|
+
const shiftLabelFor = function ( t, offsetMinutes, boundariesMin, labels ) {
|
|
53
|
+
if ( !Number.isFinite( t ) ) {
|
|
54
|
+
return null;
|
|
55
|
+
}
|
|
56
|
+
let intoDay = ( t + ( offsetMinutes * MS_PER_MIN ) ) % MS_PER_DAY;
|
|
57
|
+
if ( intoDay < 0 ) {
|
|
58
|
+
intoDay += MS_PER_DAY;
|
|
59
|
+
}
|
|
60
|
+
const minute = Math.floor( intoDay / MS_PER_MIN );
|
|
61
|
+
let idx = labels.length - 1;
|
|
62
|
+
for ( let i = 0; i < boundariesMin.length; i += 1 ) {
|
|
63
|
+
if ( minute >= boundariesMin[ i ] ) {
|
|
64
|
+
idx = i;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return labels[ idx ];
|
|
68
|
+
}; // shiftLabelFor()
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Build an in-place shift labeler.
|
|
72
|
+
*
|
|
73
|
+
* @param {Object} options
|
|
74
|
+
* @param {number[]} options.boundariesMin - shift start minutes-of-day, strictly
|
|
75
|
+
* ascending, each in [0, 1440). Required. (e.g. [ 0, 480, 960 ] for three
|
|
76
|
+
* 8-hour shifts from local midnight.)
|
|
77
|
+
* @param {string[]} options.labels - shift labels, one per boundary, same length.
|
|
78
|
+
* Required. (e.g. [ 'S1', 'S2', 'S3' ].)
|
|
79
|
+
* @param {number} [options.offsetMinutes=0] - minutes to add to epoch-UTC to reach
|
|
80
|
+
* local wall-clock time (IST = 330).
|
|
81
|
+
* @param {string} [options.field='timestamp'] - source field holding epoch ms.
|
|
82
|
+
* @param {string} [options.target='shiftLabel'] - field to write the label to.
|
|
83
|
+
* @returns {function( Object ): Object} transform( msg ) -> msg, mutated in place.
|
|
84
|
+
* A non-finite timestamp writes null (no shift known).
|
|
85
|
+
*/
|
|
86
|
+
const labelShift = function ( options ) {
|
|
87
|
+
const opts = options || {};
|
|
88
|
+
const field = fieldNameOr( opts.field, 'timestamp', 'labelShift', 'field' );
|
|
89
|
+
const target = fieldNameOr( opts.target, 'shiftLabel', 'labelShift', 'target' );
|
|
90
|
+
const offsetMinutes = finiteNumberOr( opts.offsetMinutes, 0, 'labelShift', 'offsetMinutes' );
|
|
91
|
+
// Private copies from the validators; the hot path touches only these
|
|
92
|
+
// locals and the shared lookup.
|
|
93
|
+
const bounds = validatedBoundaries( opts.boundariesMin, 'labelShift' );
|
|
94
|
+
const labels = opts.labels;
|
|
95
|
+
if ( !Array.isArray( labels ) || ( labels.length !== bounds.length ) ) {
|
|
96
|
+
throw new Error( 'winkComposer/labelShift: labels must be an array the same length as boundariesMin.' );
|
|
97
|
+
}
|
|
98
|
+
for ( let i = 0; i < labels.length; i += 1 ) {
|
|
99
|
+
if ( ( typeof labels[ i ] !== 'string' ) || ( labels[ i ] === '' ) ) {
|
|
100
|
+
throw new Error( 'winkComposer/labelShift: every label must be a non-empty string.' );
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
const names = labels.slice();
|
|
104
|
+
|
|
105
|
+
return function ( msg ) {
|
|
106
|
+
msg[ target ] = shiftLabelFor( msg[ field ], offsetMinutes, bounds, names );
|
|
107
|
+
return msg;
|
|
108
|
+
};
|
|
109
|
+
}; // labelShift()
|
|
110
|
+
|
|
111
|
+
export { labelShift, shiftLabelFor };
|
|
112
|
+
export default labelShift;
|
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
// src/tools/stream-prep/normalize-timestamp.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview normalizeTimestamp — a stream-preparation utility for a
|
|
5
|
+
* source's `transform` option. Guarantees one message field holds a numeric
|
|
6
|
+
* epoch-millisecond timestamp, in place, with no per-message allocation.
|
|
7
|
+
*
|
|
8
|
+
* Why it exists: every downstream window, dwell, and control node keys off
|
|
9
|
+
* event time, and raw feeds carry it in different shapes — epoch
|
|
10
|
+
* milliseconds, epoch seconds, an ISO-8601 string, or a plant historian's
|
|
11
|
+
* zone-less 'YYYY-MM-DD HH:mm:ss' text. A flow should not branch on which;
|
|
12
|
+
* this converts the declared field to epoch ms once, at the inlet.
|
|
13
|
+
*
|
|
14
|
+
* The input shape is fixed at init (a Knob of the feed, not sniffed per
|
|
15
|
+
* row), so the hot path carries no shape-detection branch. Two ways to
|
|
16
|
+
* declare it:
|
|
17
|
+
*
|
|
18
|
+
* unit: 'ms' the value is already epoch milliseconds
|
|
19
|
+
* unit: 's' the value is epoch seconds -> multiplied by 1000
|
|
20
|
+
* unit: 'auto' numbers pass through as ms; strings go to `Date.parse`
|
|
21
|
+
* pattern the value is text in EXACTLY this layout; parsed by a
|
|
22
|
+
* compiled reader with a declared fixed UTC offset
|
|
23
|
+
*
|
|
24
|
+
* The pattern path exists because zone-less historian text is the single
|
|
25
|
+
* most common field shape, and `Date.parse` on it is engine-dependent. The
|
|
26
|
+
* one supported pattern is 'YYYY-MM-DD HH:mm:ss'; the data may carry a
|
|
27
|
+
* trailing '.SSS' fraction (any number of digits; milliseconds kept, the
|
|
28
|
+
* rest truncated). The reader walks the string by character code and builds
|
|
29
|
+
* the epoch with the days-from-civil calendar algorithm (Howard Hinnant,
|
|
30
|
+
* "chrono-Compatible Low-Level Date Algorithms",
|
|
31
|
+
* https://howardhinnant.github.io/date_algorithms.html) — no `replace`, no
|
|
32
|
+
* `Date` object, no `Date.parse`, nothing allocated per row.
|
|
33
|
+
* `offsetMinutes` states the wall clock the text was written in (IST =
|
|
34
|
+
* 330); the epoch is shifted back to true UTC. Fixed offset only — DST
|
|
35
|
+
* sites are out of scope, same rule as labelShift.
|
|
36
|
+
*
|
|
37
|
+
* An empty or unparseable value becomes NaN, never a throw and never 0; the
|
|
38
|
+
* caller decides whether to drop the row (see filterRows).
|
|
39
|
+
*
|
|
40
|
+
* Family contract (ADR-025, stream-preparation utilities): converter and
|
|
41
|
+
* field names are captured at init; the returned function reads/writes the
|
|
42
|
+
* message in place and returns the same reference ( row -> row ).
|
|
43
|
+
*/
|
|
44
|
+
|
|
45
|
+
import { fieldNameOr, finiteNumberOr } from './validate.js';
|
|
46
|
+
|
|
47
|
+
const MS_PER_SEC = 1000;
|
|
48
|
+
const MS_PER_MIN = 60000;
|
|
49
|
+
const MS_PER_HOUR = 3600000;
|
|
50
|
+
const MS_PER_DAY = 86400000;
|
|
51
|
+
const ZERO_CODE = 48;
|
|
52
|
+
const SUPPORTED_PATTERN = 'YYYY-MM-DD HH:mm:ss';
|
|
53
|
+
|
|
54
|
+
// Days in each month (index 1-12) of a non-leap year; February's leap case is
|
|
55
|
+
// handled at the day-of-month validity check.
|
|
56
|
+
const DAYS_IN_MONTH = [ 0, 31, 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31 ];
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Coerce a raw cell to a number or NaN without allocating. Empty string and
|
|
60
|
+
* null/undefined are "no value" -> NaN (never 0), so an empty seconds cell does
|
|
61
|
+
* not become epoch 0.
|
|
62
|
+
*
|
|
63
|
+
* @param {*} value - the raw cell
|
|
64
|
+
* @returns {number} a number, possibly NaN
|
|
65
|
+
*/
|
|
66
|
+
const toNum = function ( value ) {
|
|
67
|
+
if ( typeof value === 'number' ) {
|
|
68
|
+
return value;
|
|
69
|
+
}
|
|
70
|
+
if ( ( value === null ) || ( value === undefined ) || ( value === '' ) ) {
|
|
71
|
+
return NaN;
|
|
72
|
+
}
|
|
73
|
+
return Number( value );
|
|
74
|
+
}; // toNum()
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Read a fixed-width run of ASCII digits as an integer; -1 when any position
|
|
78
|
+
* is not a digit. The -1 sentinel keeps the caller's malformed-input path a
|
|
79
|
+
* plain compare, with no exception machinery on the hot path.
|
|
80
|
+
*/
|
|
81
|
+
const readDigits = function ( s, start, len ) {
|
|
82
|
+
let out = 0;
|
|
83
|
+
for ( let i = start; i < ( start + len ); i += 1 ) {
|
|
84
|
+
const d = s.charCodeAt( i ) - ZERO_CODE;
|
|
85
|
+
if ( ( d < 0 ) || ( d > 9 ) ) {
|
|
86
|
+
return -1;
|
|
87
|
+
}
|
|
88
|
+
out = ( out * 10 ) + d;
|
|
89
|
+
}
|
|
90
|
+
return out;
|
|
91
|
+
}; // readDigits()
|
|
92
|
+
|
|
93
|
+
/** Gregorian leap-year rule. */
|
|
94
|
+
const isLeapYear = function ( y ) {
|
|
95
|
+
return ( ( ( y % 4 ) === 0 ) && ( ( y % 100 ) !== 0 ) ) || ( ( y % 400 ) === 0 );
|
|
96
|
+
}; // isLeapYear()
|
|
97
|
+
|
|
98
|
+
/** True for a real calendar date (readDigits' -1 fails the range checks). */
|
|
99
|
+
const isValidDate = function ( year, month, day ) {
|
|
100
|
+
if ( ( year < 0 ) || ( month < 1 ) || ( month > 12 ) || ( day < 1 ) ) {
|
|
101
|
+
return false;
|
|
102
|
+
}
|
|
103
|
+
const maxDay = ( ( month === 2 ) && isLeapYear( year ) ) ? 29 : DAYS_IN_MONTH[ month ];
|
|
104
|
+
return day <= maxDay;
|
|
105
|
+
}; // isValidDate()
|
|
106
|
+
|
|
107
|
+
/** True for a real time of day (readDigits' -1 fails the range checks). */
|
|
108
|
+
const isValidTime = function ( hour, minute, second ) {
|
|
109
|
+
return ( hour >= 0 ) && ( hour <= 23 ) &&
|
|
110
|
+
( minute >= 0 ) && ( minute <= 59 ) &&
|
|
111
|
+
( second >= 0 ) && ( second <= 59 );
|
|
112
|
+
}; // isValidTime()
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Read the optional '.frac' tail starting at position 19: '.' plus one or
|
|
116
|
+
* more digits. Returns milliseconds (first three digits; the rest validated
|
|
117
|
+
* but truncated), or -1 when the tail is malformed.
|
|
118
|
+
*/
|
|
119
|
+
const readFraction = function ( s, len ) {
|
|
120
|
+
if ( ( s.charCodeAt( 19 ) !== 46 ) || ( len === 20 ) ) {
|
|
121
|
+
return -1;
|
|
122
|
+
}
|
|
123
|
+
let frac = 0;
|
|
124
|
+
let scale = 100;
|
|
125
|
+
for ( let i = 20; i < len; i += 1 ) {
|
|
126
|
+
const d = s.charCodeAt( i ) - ZERO_CODE;
|
|
127
|
+
if ( ( d < 0 ) || ( d > 9 ) ) {
|
|
128
|
+
return -1;
|
|
129
|
+
}
|
|
130
|
+
if ( scale >= 1 ) {
|
|
131
|
+
frac += d * scale;
|
|
132
|
+
scale = ( scale === 1 ) ? 0 : ( scale / 10 );
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return frac;
|
|
136
|
+
}; // readFraction()
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Days from the civil epoch (1970-01-01) for a valid calendar date. This is
|
|
140
|
+
* Howard Hinnant's days_from_civil, an exact integer algorithm — see
|
|
141
|
+
* https://howardhinnant.github.io/date_algorithms.html.
|
|
142
|
+
*/
|
|
143
|
+
const daysFromCivil = function ( year, month, day ) {
|
|
144
|
+
const y = ( month <= 2 ) ? ( year - 1 ) : year;
|
|
145
|
+
const era = Math.floor( y / 400 );
|
|
146
|
+
const yoe = y - ( era * 400 );
|
|
147
|
+
const mp = ( month + 9 ) % 12;
|
|
148
|
+
const doy = Math.floor( ( ( 153 * mp ) + 2 ) / 5 ) + ( day - 1 );
|
|
149
|
+
const doe = ( yoe * 365 ) + Math.floor( yoe / 4 ) - Math.floor( yoe / 100 ) + doy;
|
|
150
|
+
return ( era * 146097 ) + doe - 719468;
|
|
151
|
+
}; // daysFromCivil()
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* Compile the reader for the one supported pattern. Returns a function that
|
|
155
|
+
* parses 'YYYY-MM-DD HH:mm:ss[.frac]' text written in a fixed-offset wall
|
|
156
|
+
* clock to true-UTC epoch ms, or NaN for anything malformed.
|
|
157
|
+
*
|
|
158
|
+
* @param {number} offsetMs - wall-clock offset from UTC in ms (IST = 330 min)
|
|
159
|
+
* @returns {function( * ): number} value -> epoch ms | NaN
|
|
160
|
+
*/
|
|
161
|
+
const compilePatternReader = function ( offsetMs ) {
|
|
162
|
+
return function ( value ) {
|
|
163
|
+
if ( ( typeof value !== 'string' ) || ( value.length < 19 ) ) {
|
|
164
|
+
return NaN;
|
|
165
|
+
}
|
|
166
|
+
const len = value.length;
|
|
167
|
+
// Fixed separators first — cheapest rejection for a wrong layout.
|
|
168
|
+
if ( ( value.charCodeAt( 4 ) !== 45 ) || ( value.charCodeAt( 7 ) !== 45 ) ||
|
|
169
|
+
( value.charCodeAt( 10 ) !== 32 ) || ( value.charCodeAt( 13 ) !== 58 ) ||
|
|
170
|
+
( value.charCodeAt( 16 ) !== 58 ) ) {
|
|
171
|
+
return NaN;
|
|
172
|
+
}
|
|
173
|
+
const year = readDigits( value, 0, 4 );
|
|
174
|
+
const month = readDigits( value, 5, 2 );
|
|
175
|
+
const day = readDigits( value, 8, 2 );
|
|
176
|
+
const hour = readDigits( value, 11, 2 );
|
|
177
|
+
const minute = readDigits( value, 14, 2 );
|
|
178
|
+
const second = readDigits( value, 17, 2 );
|
|
179
|
+
if ( !isValidDate( year, month, day ) || !isValidTime( hour, minute, second ) ) {
|
|
180
|
+
return NaN;
|
|
181
|
+
}
|
|
182
|
+
const frac = ( len > 19 ) ? readFraction( value, len ) : 0;
|
|
183
|
+
if ( frac < 0 ) {
|
|
184
|
+
return NaN;
|
|
185
|
+
}
|
|
186
|
+
const days = daysFromCivil( year, month, day );
|
|
187
|
+
return ( days * MS_PER_DAY ) + ( hour * MS_PER_HOUR ) + ( minute * MS_PER_MIN ) +
|
|
188
|
+
( second * MS_PER_SEC ) + frac - offsetMs;
|
|
189
|
+
};
|
|
190
|
+
}; // compilePatternReader()
|
|
191
|
+
|
|
192
|
+
// One converter per input shape, chosen once at init so the hot path never
|
|
193
|
+
// branches on the mode string. Dictionary with an externally-supplied key, so
|
|
194
|
+
// Object.create( null ) guards against inherited-property lookups (e.g. a mode of
|
|
195
|
+
// 'constructor' resolving to Object). Each converter returns epoch ms or NaN.
|
|
196
|
+
const CONVERTERS = Object.create( null );
|
|
197
|
+
CONVERTERS.ms = function ( value ) {
|
|
198
|
+
return toNum( value );
|
|
199
|
+
};
|
|
200
|
+
CONVERTERS.s = function ( value ) {
|
|
201
|
+
return toNum( value ) * MS_PER_SEC;
|
|
202
|
+
};
|
|
203
|
+
CONVERTERS.auto = function ( value ) {
|
|
204
|
+
return ( typeof value === 'number' ) ? value : Date.parse( value );
|
|
205
|
+
};
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Pick the converter from the options: a compiled pattern reader when
|
|
209
|
+
* `pattern` is declared, one of the unit converters otherwise. Init-time
|
|
210
|
+
* only; all option conflicts are rejected here.
|
|
211
|
+
*/
|
|
212
|
+
const resolveConverter = function ( opts ) {
|
|
213
|
+
if ( opts.pattern === undefined ) {
|
|
214
|
+
if ( opts.offsetMinutes !== undefined ) {
|
|
215
|
+
throw new Error( 'winkComposer/normalizeTimestamp: offsetMinutes applies to pattern only.' );
|
|
216
|
+
}
|
|
217
|
+
const unit = ( opts.unit === undefined ) ? 'auto' : opts.unit;
|
|
218
|
+
const convert = CONVERTERS[ unit ];
|
|
219
|
+
if ( typeof convert !== 'function' ) {
|
|
220
|
+
throw new Error( 'winkComposer/normalizeTimestamp: unit must be one of ms, s, auto (got ' + unit + ').' );
|
|
221
|
+
}
|
|
222
|
+
return convert;
|
|
223
|
+
}
|
|
224
|
+
if ( opts.unit !== undefined ) {
|
|
225
|
+
throw new Error( 'winkComposer/normalizeTimestamp: unit and pattern are mutually exclusive.' );
|
|
226
|
+
}
|
|
227
|
+
if ( opts.pattern !== SUPPORTED_PATTERN ) {
|
|
228
|
+
throw new Error( 'winkComposer/normalizeTimestamp: the supported pattern is \'' + SUPPORTED_PATTERN + '\' (got ' + opts.pattern + ').' );
|
|
229
|
+
}
|
|
230
|
+
const offsetMinutes = finiteNumberOr( opts.offsetMinutes, 0, 'normalizeTimestamp', 'offsetMinutes' );
|
|
231
|
+
return compilePatternReader( offsetMinutes * MS_PER_MIN );
|
|
232
|
+
}; // resolveConverter()
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Build an in-place timestamp normalizer.
|
|
236
|
+
*
|
|
237
|
+
* @param {Object} [options]
|
|
238
|
+
* @param {string} [options.field='timestamp'] - source field to read
|
|
239
|
+
* @param {string} [options.target=field] - field to write; defaults to the source
|
|
240
|
+
* field (in place). Set it when the raw column name differs from the field the
|
|
241
|
+
* flow reads (e.g. read 'ts', write 'timestamp').
|
|
242
|
+
* @param {string} [options.unit='auto'] - input shape: 'ms' | 's' | 'auto'.
|
|
243
|
+
* Mutually exclusive with pattern.
|
|
244
|
+
* @param {string} [options.pattern] - fixed text layout; the one supported
|
|
245
|
+
* value is 'YYYY-MM-DD HH:mm:ss' (data may carry a trailing '.SSS'
|
|
246
|
+
* fraction). Mutually exclusive with unit.
|
|
247
|
+
* @param {number} [options.offsetMinutes=0] - with pattern only: the fixed
|
|
248
|
+
* UTC offset of the wall clock the text was written in (IST = 330).
|
|
249
|
+
* @returns {function( Object ): Object} transform( msg ) -> msg, mutated in place
|
|
250
|
+
*/
|
|
251
|
+
const normalizeTimestamp = function ( options ) {
|
|
252
|
+
const opts = options || {};
|
|
253
|
+
const field = fieldNameOr( opts.field, 'timestamp', 'normalizeTimestamp', 'field' );
|
|
254
|
+
const target = fieldNameOr( opts.target, field, 'normalizeTimestamp', 'target' );
|
|
255
|
+
const convert = resolveConverter( opts );
|
|
256
|
+
|
|
257
|
+
return function ( msg ) {
|
|
258
|
+
const out = convert( msg[ field ] );
|
|
259
|
+
msg[ target ] = Number.isFinite( out ) ? out : NaN;
|
|
260
|
+
return msg;
|
|
261
|
+
};
|
|
262
|
+
}; // normalizeTimestamp()
|
|
263
|
+
|
|
264
|
+
export { normalizeTimestamp };
|
|
265
|
+
export default normalizeTimestamp;
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// src/tools/stream-prep/stamp-period.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview stampPeriod — a stream-preparation utility for a source's
|
|
5
|
+
* `transform` option. Stamps each message with a monotonic integer key naming
|
|
6
|
+
* the calendar period — the local day, or the shift — its timestamp falls in.
|
|
7
|
+
* In place, with no per-message allocation.
|
|
8
|
+
*
|
|
9
|
+
* Why a key and not a "period rolled" flag: the roll (the first message of a
|
|
10
|
+
* new period, per asset) needs per-asset state, and per-asset state belongs
|
|
11
|
+
* to partitions. With the key on the message, a per-partition
|
|
12
|
+
* `stateChangeDetector` on it gives the per-asset roll edge in-flow, and a
|
|
13
|
+
* `controller` can flush windows on it. The utility stays stateless and
|
|
14
|
+
* stream-level; the flow owns the roll.
|
|
15
|
+
*
|
|
16
|
+
* The keys and their arithmetic (all integer, no Date objects):
|
|
17
|
+
* 'day' dayIndex = floor( (t + offset) / 86400000 ) — days since the
|
|
18
|
+
* epoch, counted in local wall-clock time (fixed offset).
|
|
19
|
+
* 'shift' shiftKey = (dayIndex * shiftsPerDay) + shiftIdx — unique and
|
|
20
|
+
* monotonic across days. A time earlier than the first boundary
|
|
21
|
+
* belongs to the PREVIOUS day's last shift (it wrapped past
|
|
22
|
+
* midnight), so the key never jumps backward at a boundary.
|
|
23
|
+
*
|
|
24
|
+
* Local time is a FIXED offset from UTC, same rule as labelShift: DST sites
|
|
25
|
+
* are out of scope; resolve local time upstream there.
|
|
26
|
+
*
|
|
27
|
+
* Family contract (ADR-025, stream-preparation utilities): configuration is
|
|
28
|
+
* captured once at init; the returned function mutates the message in place,
|
|
29
|
+
* creates no objects per message, and returns the same reference
|
|
30
|
+
* ( row -> row ). A non-finite timestamp writes null (no period known).
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import { fieldNameOr, finiteNumberOr, validatedBoundaries } from './validate.js';
|
|
34
|
+
|
|
35
|
+
const MS_PER_MIN = 60000;
|
|
36
|
+
const MS_PER_DAY = 86400000;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Build an in-place period-key stamper.
|
|
40
|
+
*
|
|
41
|
+
* @param {Object} options
|
|
42
|
+
* @param {string} options.period - 'day' or 'shift'. Required.
|
|
43
|
+
* @param {number[]} [options.boundariesMin] - shift start minutes-of-day,
|
|
44
|
+
* strictly ascending, each in [0, 1440). Required for 'shift';
|
|
45
|
+
* not allowed for 'day'.
|
|
46
|
+
* @param {number} [options.offsetMinutes=0] - minutes to add to epoch-UTC to
|
|
47
|
+
* reach local wall-clock time (IST = 330).
|
|
48
|
+
* @param {string} [options.field='timestamp'] - source field holding epoch ms.
|
|
49
|
+
* @param {string} [options.target] - field to write the key to. Defaults to
|
|
50
|
+
* 'dayKey' or 'shiftKey' by period.
|
|
51
|
+
* @returns {function( Object ): Object} transform( msg ) -> msg, mutated in
|
|
52
|
+
* place. A non-finite timestamp writes null.
|
|
53
|
+
*/
|
|
54
|
+
const stampPeriod = function ( options ) {
|
|
55
|
+
const opts = options || {};
|
|
56
|
+
const period = opts.period;
|
|
57
|
+
if ( ( period !== 'day' ) && ( period !== 'shift' ) ) {
|
|
58
|
+
throw new Error( 'winkComposer/stampPeriod: period must be day or shift.' );
|
|
59
|
+
}
|
|
60
|
+
const field = fieldNameOr( opts.field, 'timestamp', 'stampPeriod', 'field' );
|
|
61
|
+
const defaultTarget = ( period === 'day' ) ? 'dayKey' : 'shiftKey';
|
|
62
|
+
const target = fieldNameOr( opts.target, defaultTarget, 'stampPeriod', 'target' );
|
|
63
|
+
const offsetMinutes = finiteNumberOr( opts.offsetMinutes, 0, 'stampPeriod', 'offsetMinutes' );
|
|
64
|
+
const offsetMs = offsetMinutes * MS_PER_MIN;
|
|
65
|
+
|
|
66
|
+
if ( period === 'day' ) {
|
|
67
|
+
if ( opts.boundariesMin !== undefined ) {
|
|
68
|
+
throw new Error( 'winkComposer/stampPeriod: boundariesMin applies to shift only.' );
|
|
69
|
+
}
|
|
70
|
+
return function ( msg ) {
|
|
71
|
+
const t = msg[ field ];
|
|
72
|
+
msg[ target ] = Number.isFinite( t ) ? Math.floor( ( t + offsetMs ) / MS_PER_DAY ) : null;
|
|
73
|
+
return msg;
|
|
74
|
+
};
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// period === 'shift' — the schedule is validated exactly as labelShift's.
|
|
78
|
+
const bounds = validatedBoundaries( opts.boundariesMin, 'stampPeriod' );
|
|
79
|
+
const shiftsPerDay = bounds.length;
|
|
80
|
+
|
|
81
|
+
return function ( msg ) {
|
|
82
|
+
const t = msg[ field ];
|
|
83
|
+
if ( !Number.isFinite( t ) ) {
|
|
84
|
+
msg[ target ] = null;
|
|
85
|
+
return msg;
|
|
86
|
+
}
|
|
87
|
+
const local = t + offsetMs;
|
|
88
|
+
const dayIndex = Math.floor( local / MS_PER_DAY );
|
|
89
|
+
let intoDay = local % MS_PER_DAY;
|
|
90
|
+
if ( intoDay < 0 ) {
|
|
91
|
+
intoDay += MS_PER_DAY;
|
|
92
|
+
}
|
|
93
|
+
const minute = Math.floor( intoDay / MS_PER_MIN );
|
|
94
|
+
if ( minute < bounds[ 0 ] ) {
|
|
95
|
+
// Wrapped past midnight: previous day's last shift, keeping the key monotonic.
|
|
96
|
+
msg[ target ] = ( ( dayIndex - 1 ) * shiftsPerDay ) + ( shiftsPerDay - 1 );
|
|
97
|
+
return msg;
|
|
98
|
+
}
|
|
99
|
+
let idx = 0;
|
|
100
|
+
for ( let i = 0; i < shiftsPerDay; i += 1 ) {
|
|
101
|
+
if ( minute >= bounds[ i ] ) {
|
|
102
|
+
idx = i;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
msg[ target ] = ( dayIndex * shiftsPerDay ) + idx;
|
|
106
|
+
return msg;
|
|
107
|
+
};
|
|
108
|
+
}; // stampPeriod()
|
|
109
|
+
|
|
110
|
+
export { stampPeriod };
|
|
111
|
+
export default stampPeriod;
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
// src/tools/stream-prep/track-activity.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview trackActivity — a stream-preparation utility for a source's
|
|
5
|
+
* `transform` option. It turns an intermittent activity signal into a
|
|
6
|
+
* sustained active/idle state and reports how long that state has held, in
|
|
7
|
+
* place, allocating nothing per message.
|
|
8
|
+
*
|
|
9
|
+
* Why it exists: many plant questions reduce to "has this thing shown
|
|
10
|
+
* activity recently, and for how long?" — is the conveyor line running (a
|
|
11
|
+
* pulse toggled lately), is a sensor dead or stuck (its value has not changed
|
|
12
|
+
* in a long time), is a machine idle (its meter has stopped advancing). These
|
|
13
|
+
* are one measurement read from two ends: track the time since the last
|
|
14
|
+
* activity; recent means active, stale means idle/dead. This utility is that
|
|
15
|
+
* one measurement.
|
|
16
|
+
*
|
|
17
|
+
* What counts as "activity": a watched field's value CHANGED since the
|
|
18
|
+
* previous message. With several fields, any one changing is activity.
|
|
19
|
+
* `epsilon` ignores a numeric change smaller than itself (consecutive-step
|
|
20
|
+
* jitter). A field with no value this message (null / undefined / NaN)
|
|
21
|
+
* contributes no activity and does not disturb the baseline.
|
|
22
|
+
*
|
|
23
|
+
* The state model: hold "active" for `windowSec` after the last activity.
|
|
24
|
+
* `active` starts false and turns true only once a change has actually been
|
|
25
|
+
* observed — a gate never claims "running" without evidence, and a signal
|
|
26
|
+
* that never changes reads as never-active (dead). Reported per message:
|
|
27
|
+
* active is it active now (a change within the window)
|
|
28
|
+
* activeFor ms the current active run has lasted (0 when idle)
|
|
29
|
+
* sinceActivity ms since the last activity (null before any; the "stale" end)
|
|
30
|
+
* activeStart epoch-ms clock reading when the current active run began. Unlike
|
|
31
|
+
* the others it HOLDS its value while idle, keeping the last run's
|
|
32
|
+
* start, so an event that ends after the run stopped can still be
|
|
33
|
+
* dated back to the run it belonged to (null before any activity).
|
|
34
|
+
*
|
|
35
|
+
* Scope: this is for a STREAM-LEVEL / shared signal (a line, a plant)
|
|
36
|
+
* computed once upstream of partitioning. Because it compares to the previous
|
|
37
|
+
* message, it is correct when the watched fields are the same across a tick's
|
|
38
|
+
* messages (as shared line pulses are) — the change is caught once on the
|
|
39
|
+
* tick boundary and every message of that tick gets the same reading.
|
|
40
|
+
* Per-asset activity (a different value per message) needs per-key state,
|
|
41
|
+
* which is out of scope here.
|
|
42
|
+
*
|
|
43
|
+
* Family contract (ADR-025): fields, window, and output names are captured at
|
|
44
|
+
* init; the previous-value store is one object built once. The returned
|
|
45
|
+
* function reads and writes the message in place, creates nothing per
|
|
46
|
+
* message, and returns the same message so it drops into a source `transform`
|
|
47
|
+
* slot ( row -> row ). Durations are clamped to >= 0; timestamps are assumed
|
|
48
|
+
* monotonic per stream.
|
|
49
|
+
*/
|
|
50
|
+
|
|
51
|
+
import { fieldNameOr, finiteNumberOr } from './validate.js';
|
|
52
|
+
|
|
53
|
+
/** True when a value is "no value this message" — never counts as activity. */
|
|
54
|
+
const isNoValue = function ( v ) {
|
|
55
|
+
return ( v === null ) || ( v === undefined ) || ( ( typeof v === 'number' ) && Number.isNaN( v ) );
|
|
56
|
+
}; // isNoValue()
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Whether a value differs from its predecessor. Numbers compare by magnitude
|
|
60
|
+
* against `epsilon` (jitter gate); anything else compares strictly.
|
|
61
|
+
*/
|
|
62
|
+
const differs = function ( cur, prev, epsilon ) {
|
|
63
|
+
if ( ( typeof cur === 'number' ) && ( typeof prev === 'number' ) ) {
|
|
64
|
+
return Math.abs( cur - prev ) > epsilon;
|
|
65
|
+
}
|
|
66
|
+
return cur !== prev;
|
|
67
|
+
}; // differs()
|
|
68
|
+
|
|
69
|
+
const ALLOWED_WRITES = [ 'active', 'activeFor', 'sinceActivity', 'activeStart' ];
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the `from` option to a private array of watched field names.
|
|
73
|
+
* Init-time only; throws on anything that is not a name or a non-empty
|
|
74
|
+
* array of names.
|
|
75
|
+
*/
|
|
76
|
+
const resolveFields = function ( from ) {
|
|
77
|
+
if ( typeof from === 'string' ) {
|
|
78
|
+
if ( from === '' ) {
|
|
79
|
+
throw new Error( 'winkComposer/trackActivity: from must be a non-empty field name.' );
|
|
80
|
+
}
|
|
81
|
+
return [ from ];
|
|
82
|
+
}
|
|
83
|
+
if ( Array.isArray( from ) && ( from.length > 0 ) ) {
|
|
84
|
+
for ( let i = 0; i < from.length; i += 1 ) {
|
|
85
|
+
if ( ( typeof from[ i ] !== 'string' ) || ( from[ i ] === '' ) ) {
|
|
86
|
+
throw new Error( 'winkComposer/trackActivity: every from field must be a non-empty string.' );
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return from.slice();
|
|
90
|
+
}
|
|
91
|
+
throw new Error( 'winkComposer/trackActivity: from must be a field name or a non-empty array of field names.' );
|
|
92
|
+
}; // resolveFields()
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Resolve the `writes` option to the four output names (null = not stamped).
|
|
96
|
+
* Init-time only; rejects unknown keys and empty names.
|
|
97
|
+
*/
|
|
98
|
+
const resolveWrites = function ( writes ) {
|
|
99
|
+
if ( ( typeof writes !== 'object' ) || ( writes === null ) || Array.isArray( writes ) ) {
|
|
100
|
+
throw new Error( 'winkComposer/trackActivity: writes must be an object naming at least one output.' );
|
|
101
|
+
}
|
|
102
|
+
const writeKeys = Object.keys( writes );
|
|
103
|
+
if ( writeKeys.length === 0 ) {
|
|
104
|
+
throw new Error( 'winkComposer/trackActivity: writes must name at least one of active, activeFor, sinceActivity, activeStart.' );
|
|
105
|
+
}
|
|
106
|
+
for ( let i = 0; i < writeKeys.length; i += 1 ) {
|
|
107
|
+
const key = writeKeys[ i ];
|
|
108
|
+
if ( !ALLOWED_WRITES.includes( key ) ) {
|
|
109
|
+
throw new Error( 'winkComposer/trackActivity: unknown write \'' + key + '\' (allowed: active, activeFor, sinceActivity, activeStart).' );
|
|
110
|
+
}
|
|
111
|
+
if ( ( typeof writes[ key ] !== 'string' ) || ( writes[ key ] === '' ) ) {
|
|
112
|
+
throw new Error( 'winkComposer/trackActivity: writes.' + key + ' must be a non-empty field name.' );
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return {
|
|
116
|
+
active: ( writes.active === undefined ) ? null : writes.active,
|
|
117
|
+
activeFor: ( writes.activeFor === undefined ) ? null : writes.activeFor,
|
|
118
|
+
sinceActivity: ( writes.sinceActivity === undefined ) ? null : writes.sinceActivity,
|
|
119
|
+
activeStart: ( writes.activeStart === undefined ) ? null : writes.activeStart
|
|
120
|
+
};
|
|
121
|
+
}; // resolveWrites()
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Build an in-place activity-state stamper.
|
|
125
|
+
*
|
|
126
|
+
* @param {Object} options
|
|
127
|
+
* @param {string|string[]} options.from - field(s) to watch for change. Required.
|
|
128
|
+
* @param {number} options.windowSec - hold "active" this long after the last
|
|
129
|
+
* activity (seconds). Required, positive.
|
|
130
|
+
* @param {number} [options.epsilon=0] - ignore a numeric change at or below this
|
|
131
|
+
* between consecutive messages (a noise gate; 0 = any change counts).
|
|
132
|
+
* @param {string} [options.timestampField='timestamp'] - epoch-ms clock field.
|
|
133
|
+
* @param {Object} options.writes - names the outputs to stamp; at least one of
|
|
134
|
+
* `active`, `activeFor`, `sinceActivity`, `activeStart`, each a non-empty field name.
|
|
135
|
+
* @returns {function( Object ): Object} transform( msg ) -> msg, mutated in place.
|
|
136
|
+
* A non-finite timestamp writes null to the configured outputs and does not
|
|
137
|
+
* advance the state.
|
|
138
|
+
*/
|
|
139
|
+
const trackActivity = function ( options ) {
|
|
140
|
+
const opts = options || {};
|
|
141
|
+
const fields = resolveFields( opts.from );
|
|
142
|
+
const windowSec = finiteNumberOr( opts.windowSec, undefined, 'trackActivity', 'windowSec' );
|
|
143
|
+
if ( windowSec <= 0 ) {
|
|
144
|
+
throw new Error( 'winkComposer/trackActivity: windowSec must be a positive number.' );
|
|
145
|
+
}
|
|
146
|
+
const windowMs = windowSec * 1000;
|
|
147
|
+
const epsilon = finiteNumberOr( opts.epsilon, 0, 'trackActivity', 'epsilon' );
|
|
148
|
+
if ( epsilon < 0 ) {
|
|
149
|
+
throw new Error( 'winkComposer/trackActivity: epsilon must be a non-negative number.' );
|
|
150
|
+
}
|
|
151
|
+
const tsField = fieldNameOr( opts.timestampField, 'timestamp', 'trackActivity', 'timestampField' );
|
|
152
|
+
const w = resolveWrites( opts.writes );
|
|
153
|
+
|
|
154
|
+
// State captured at init — the hot path allocates nothing. prev is a private
|
|
155
|
+
// dictionary of last-seen values ( external field-name keys -> Object.create(null) ).
|
|
156
|
+
const n = fields.length;
|
|
157
|
+
const prev = Object.create( null );
|
|
158
|
+
let lastActivityTs = null;
|
|
159
|
+
let activeStartTs = null;
|
|
160
|
+
let wasActive = false;
|
|
161
|
+
|
|
162
|
+
return function ( msg ) {
|
|
163
|
+
const t = msg[ tsField ];
|
|
164
|
+
if ( !Number.isFinite( t ) ) {
|
|
165
|
+
if ( w.active !== null ) {
|
|
166
|
+
msg[ w.active ] = null;
|
|
167
|
+
}
|
|
168
|
+
if ( w.activeFor !== null ) {
|
|
169
|
+
msg[ w.activeFor ] = null;
|
|
170
|
+
}
|
|
171
|
+
if ( w.sinceActivity !== null ) {
|
|
172
|
+
msg[ w.sinceActivity ] = null;
|
|
173
|
+
}
|
|
174
|
+
if ( w.activeStart !== null ) {
|
|
175
|
+
msg[ w.activeStart ] = null;
|
|
176
|
+
}
|
|
177
|
+
return msg;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
let activity = false;
|
|
181
|
+
for ( let i = 0; i < n; i += 1 ) {
|
|
182
|
+
const f = fields[ i ];
|
|
183
|
+
const cur = msg[ f ];
|
|
184
|
+
if ( !isNoValue( cur ) ) {
|
|
185
|
+
const p = prev[ f ];
|
|
186
|
+
if ( p === undefined ) {
|
|
187
|
+
prev[ f ] = cur; // first value for this field — seed, no change
|
|
188
|
+
} else {
|
|
189
|
+
if ( differs( cur, p, epsilon ) ) {
|
|
190
|
+
activity = true;
|
|
191
|
+
}
|
|
192
|
+
prev[ f ] = cur;
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
if ( activity ) {
|
|
198
|
+
lastActivityTs = t;
|
|
199
|
+
}
|
|
200
|
+
const active = ( lastActivityTs !== null ) && ( ( t - lastActivityTs ) <= windowMs );
|
|
201
|
+
if ( active && ( wasActive === false ) ) {
|
|
202
|
+
activeStartTs = t; // rising edge — the active run begins now
|
|
203
|
+
}
|
|
204
|
+
wasActive = active;
|
|
205
|
+
|
|
206
|
+
if ( w.active !== null ) {
|
|
207
|
+
msg[ w.active ] = active;
|
|
208
|
+
}
|
|
209
|
+
if ( w.activeFor !== null ) {
|
|
210
|
+
msg[ w.activeFor ] = active ? Math.max( 0, t - activeStartTs ) : 0;
|
|
211
|
+
}
|
|
212
|
+
if ( w.sinceActivity !== null ) {
|
|
213
|
+
msg[ w.sinceActivity ] = ( lastActivityTs === null ) ? null : Math.max( 0, t - lastActivityTs );
|
|
214
|
+
}
|
|
215
|
+
if ( w.activeStart !== null ) {
|
|
216
|
+
msg[ w.activeStart ] = activeStartTs;
|
|
217
|
+
}
|
|
218
|
+
return msg;
|
|
219
|
+
};
|
|
220
|
+
}; // trackActivity()
|
|
221
|
+
|
|
222
|
+
export { trackActivity };
|
|
223
|
+
export default trackActivity;
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// src/tools/stream-prep/validate.js
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @fileoverview Shared init-time validation helpers for the stream-preparation
|
|
5
|
+
* utilities. Nothing here runs on the hot path — every helper is called once,
|
|
6
|
+
* inside a factory, and throws immediately with the calling utility's name
|
|
7
|
+
* leading the message, so a bad config names its own site (fail-fast, the
|
|
8
|
+
* ADR-025 family contract; same error style as the training utilities).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
const MINUTES_PER_DAY = 1440;
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Resolve an optional field-name option: undefined takes the fallback; the
|
|
15
|
+
* result must be a non-empty string.
|
|
16
|
+
*
|
|
17
|
+
* @param {*} value - the option as given
|
|
18
|
+
* @param {string} fallback - default when the option is undefined
|
|
19
|
+
* @param {string} ctx - calling utility name, for the error message
|
|
20
|
+
* @param {string} name - option name, for the error message
|
|
21
|
+
* @returns {string} the validated field name
|
|
22
|
+
*/
|
|
23
|
+
const fieldNameOr = function ( value, fallback, ctx, name ) {
|
|
24
|
+
const out = ( value === undefined ) ? fallback : value;
|
|
25
|
+
if ( ( typeof out !== 'string' ) || ( out === '' ) ) {
|
|
26
|
+
throw new Error( 'winkComposer/' + ctx + ': ' + name + ' must be a non-empty string.' );
|
|
27
|
+
}
|
|
28
|
+
return out;
|
|
29
|
+
}; // fieldNameOr()
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Resolve an optional numeric option: undefined takes the fallback; the
|
|
33
|
+
* result must be a finite number.
|
|
34
|
+
*
|
|
35
|
+
* @param {*} value - the option as given
|
|
36
|
+
* @param {number} fallback - default when the option is undefined
|
|
37
|
+
* @param {string} ctx - calling utility name, for the error message
|
|
38
|
+
* @param {string} name - option name, for the error message
|
|
39
|
+
* @returns {number} the validated number
|
|
40
|
+
*/
|
|
41
|
+
const finiteNumberOr = function ( value, fallback, ctx, name ) {
|
|
42
|
+
const out = ( value === undefined ) ? fallback : value;
|
|
43
|
+
if ( ( typeof out !== 'number' ) || !Number.isFinite( out ) ) {
|
|
44
|
+
throw new Error( 'winkComposer/' + ctx + ': ' + name + ' must be a finite number.' );
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}; // finiteNumberOr()
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Validate a shift schedule's boundary list: a non-empty array of strictly
|
|
51
|
+
* ascending minutes-of-day, each in [0, 1440). Returns a private copy so the
|
|
52
|
+
* caller's hot path cannot be disturbed by later mutation of the original.
|
|
53
|
+
*
|
|
54
|
+
* @param {*} boundariesMin - the option as given
|
|
55
|
+
* @param {string} ctx - calling utility name, for the error message
|
|
56
|
+
* @returns {number[]} a validated private copy
|
|
57
|
+
*/
|
|
58
|
+
const validatedBoundaries = function ( boundariesMin, ctx ) {
|
|
59
|
+
if ( !Array.isArray( boundariesMin ) || ( boundariesMin.length === 0 ) ) {
|
|
60
|
+
throw new Error( 'winkComposer/' + ctx + ': boundariesMin must be a non-empty array.' );
|
|
61
|
+
}
|
|
62
|
+
let prev = -1;
|
|
63
|
+
for ( let i = 0; i < boundariesMin.length; i += 1 ) {
|
|
64
|
+
const b = boundariesMin[ i ];
|
|
65
|
+
if ( ( typeof b !== 'number' ) || !Number.isFinite( b ) || ( b < 0 ) || ( b >= MINUTES_PER_DAY ) ) {
|
|
66
|
+
throw new Error( 'winkComposer/' + ctx + ': each boundary must be a minute-of-day in [0, 1440).' );
|
|
67
|
+
}
|
|
68
|
+
if ( b <= prev ) {
|
|
69
|
+
throw new Error( 'winkComposer/' + ctx + ': boundariesMin must be strictly ascending.' );
|
|
70
|
+
}
|
|
71
|
+
prev = b;
|
|
72
|
+
}
|
|
73
|
+
return boundariesMin.slice();
|
|
74
|
+
}; // validatedBoundaries()
|
|
75
|
+
|
|
76
|
+
export { fieldNameOr, finiteNumberOr, validatedBoundaries };
|