node-red-contrib-event-calc 3.3.6 → 3.3.15

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.
@@ -5,7 +5,7 @@
5
5
  * - Maps variables to exact topics
6
6
  * - Evaluates JavaScript expressions when inputs update
7
7
  * - Trigger modes: 'any' (any input updates) or 'all' (all inputs have values)
8
- * - Safe expression evaluation using Function constructor
8
+ * - Cached compiled functions for high throughput
9
9
  * - Dynamic expression update via input message
10
10
  * - Built-in helper functions for common operations
11
11
  */
@@ -16,17 +16,17 @@ module.exports = function(RED) {
16
16
  // Math shortcuts
17
17
  min: (...args) => Math.min(...args.flat()),
18
18
  max: (...args) => Math.max(...args.flat()),
19
- abs: (x) => Math.abs(x),
20
- sqrt: (x) => Math.sqrt(x),
21
- pow: (base, exp) => Math.pow(base, exp),
22
- log: (x) => Math.log(x),
23
- log10: (x) => Math.log10(x),
24
- exp: (x) => Math.exp(x),
25
- floor: (x) => Math.floor(x),
26
- ceil: (x) => Math.ceil(x),
27
- sin: (x) => Math.sin(x),
28
- cos: (x) => Math.cos(x),
29
- tan: (x) => Math.tan(x),
19
+ abs: Math.abs,
20
+ sqrt: Math.sqrt,
21
+ pow: Math.pow,
22
+ log: Math.log,
23
+ log10: Math.log10,
24
+ exp: Math.exp,
25
+ floor: Math.floor,
26
+ ceil: Math.ceil,
27
+ sin: Math.sin,
28
+ cos: Math.cos,
29
+ tan: Math.tan,
30
30
  PI: Math.PI,
31
31
  E: Math.E,
32
32
 
@@ -74,6 +74,11 @@ module.exports = function(RED) {
74
74
  : h >= startHour || h < endHour; // wraps midnight
75
75
  }
76
76
  };
77
+
78
+ // Pre-compute helper keys (shared across all nodes, never changes)
79
+ const helperKeys = Object.keys(helpers);
80
+ const helperValues = Object.values(helpers);
81
+
77
82
  function EventCalcNode(config) {
78
83
  RED.nodes.createNode(this, config);
79
84
  const node = this;
@@ -111,21 +116,89 @@ module.exports = function(RED) {
111
116
  }
112
117
  }
113
118
 
119
+ // Subscribe to inputs
120
+ const latestValues = new Map();
121
+
122
+ // Dynamic helpers that need access to cache (created per-node)
123
+ const cacheHelpers = {
124
+ now: Date.now,
125
+ hasChanged: (varName) => {
126
+ const data = latestValues.get(varName);
127
+ if (!data || !data.topic) return false;
128
+ const entry = node.cacheConfig.getValue(data.topic);
129
+ if (!entry || !entry.previous) return false;
130
+ return entry.value !== entry.previous.value;
131
+ },
132
+ timeSinceLastChange: (varName) => {
133
+ const data = latestValues.get(varName);
134
+ if (!data || !data.topic) return 0;
135
+ const entry = node.cacheConfig.getValue(data.topic);
136
+ if (!entry) return 0;
137
+ if (!entry.previous || entry.value === entry.previous.value) {
138
+ return Date.now() - (entry.previous ? entry.previous.ts : entry.ts);
139
+ }
140
+ return Date.now() - entry.ts;
141
+ },
142
+ prev: (varName) => {
143
+ const data = latestValues.get(varName);
144
+ if (!data || !data.topic) return undefined;
145
+ const prev = node.cacheConfig.getPrevious(data.topic);
146
+ return prev ? prev.value : undefined;
147
+ }
148
+ };
149
+
150
+ // Pre-compute cache helper keys
151
+ const cacheHelperKeys = Object.keys(cacheHelpers);
152
+ const cacheHelperValues = Object.values(cacheHelpers);
153
+
154
+ // Pre-compute input variable names (order is stable)
155
+ const inputNames = node.inputMappings.map(m => m.name);
156
+
157
+ // --- Compiled function cache ---
158
+ // All param names = helpers + cacheHelpers + input variable names
159
+ // helpers and cacheHelpers are fixed; input names are fixed per node
160
+ const allParamNames = [...helperKeys, ...cacheHelperKeys, ...inputNames];
161
+ // Pre-allocate the values array (reused on every call)
162
+ const allParamValues = new Array(allParamNames.length);
163
+ // Fill the fixed portion (helpers + cacheHelpers)
164
+ const fixedCount = helperKeys.length + cacheHelperKeys.length;
165
+ for (let i = 0; i < helperKeys.length; i++) {
166
+ allParamValues[i] = helperValues[i];
167
+ }
168
+ for (let i = 0; i < cacheHelperKeys.length; i++) {
169
+ allParamValues[helperKeys.length + i] = cacheHelperValues[i];
170
+ }
171
+
172
+ // Compiled function + expression it was compiled from
173
+ let compiledFn = null;
174
+ let compiledExpression = '';
175
+
176
+ function compileExpression(expr) {
177
+ if (expr === compiledExpression && compiledFn) return compiledFn;
178
+ compiledFn = new Function(...allParamNames, `return ${expr};`);
179
+ compiledExpression = expr;
180
+ return compiledFn;
181
+ }
182
+
183
+ // Compile initial expression
184
+ try {
185
+ compileExpression(node.expression);
186
+ } catch (err) {
187
+ node.status({ fill: "red", shape: "ring", text: "compile error" });
188
+ }
189
+
114
190
  /**
115
191
  * Attempt to calculate and output result
116
- * @param {string} triggerTopic - Topic that triggered the calculation
117
- * @param {Map} latestValues - Current cached values
118
- * @param {number} triggerTs - Timestamp of the triggering event
119
192
  */
120
- function tryCalculate(triggerTopic, latestValues, triggerTs) {
193
+ function tryCalculate(triggerTopic, triggerTs) {
121
194
  // Ignore updates triggered by our own output
122
195
  if (triggerTopic === node.outputTopic) {
123
196
  return;
124
197
  }
125
198
 
126
199
  if (node.triggerOn === 'all') {
127
- for (const input of node.inputMappings) {
128
- if (!latestValues.has(input.name)) {
200
+ for (let i = 0; i < inputNames.length; i++) {
201
+ if (!latestValues.has(inputNames[i])) {
129
202
  return;
130
203
  }
131
204
  }
@@ -135,72 +208,45 @@ module.exports = function(RED) {
135
208
  return;
136
209
  }
137
210
 
138
- const context = {};
139
- const inputDetails = {};
140
- const missingInputs = [];
141
-
142
- for (const input of node.inputMappings) {
143
- const data = latestValues.get(input.name);
211
+ // Fill input variable values into the pre-allocated array
212
+ let hasAllInputs = true;
213
+ for (let i = 0; i < inputNames.length; i++) {
214
+ const data = latestValues.get(inputNames[i]);
144
215
  if (data && data.value !== undefined && data.value !== null) {
145
- context[input.name] = data.value;
146
- inputDetails[input.name] = {
147
- topic: data.topic,
148
- value: data.value,
149
- ts: data.ts
150
- };
216
+ allParamValues[fixedCount + i] = data.value;
151
217
  } else {
152
- context[input.name] = undefined;
153
- missingInputs.push(input.name);
218
+ allParamValues[fixedCount + i] = undefined;
219
+ hasAllInputs = false;
154
220
  }
155
221
  }
156
222
 
157
- // Build topics mapping: variable name -> topic
158
- const topics = { _output: node.outputTopic };
159
- const timestamps = {};
160
- for (const [name, details] of Object.entries(inputDetails)) {
161
- topics[name] = details.topic;
162
- timestamps[name] = details.ts;
163
- }
164
-
165
223
  try {
166
- const allParams = { ...helpers, ...cacheHelpers, ...context };
167
- const paramNames = Object.keys(allParams);
168
- const paramValues = Object.values(allParams);
169
-
170
- const fn = new Function(...paramNames, `return ${node.expression};`);
171
- const result = fn(...paramValues);
224
+ const fn = compileExpression(node.expression);
225
+ const result = fn(...allParamValues);
172
226
 
173
227
  // Check for NaN or invalid result
174
228
  if (typeof result === 'number' && isNaN(result)) {
175
- const errorMsg = {
229
+ node.send([null, {
176
230
  topic: node.outputTopic,
177
- payload: {
178
- error: 'Expression resulted in NaN',
179
- missingInputs: missingInputs,
180
- expression: node.expression
181
- },
231
+ payload: { error: 'Expression resulted in NaN', expression: node.expression },
182
232
  trigger: triggerTopic,
183
233
  ts: triggerTs
184
- };
185
- node.send([null, errorMsg]);
234
+ }]);
186
235
  node.status({ fill: "yellow", shape: "ring", text: "NaN" });
187
236
  return;
188
237
  }
189
238
 
190
- const msg = {
239
+ node.send([{
191
240
  topic: node.outputTopic,
192
241
  payload: result,
193
242
  expression: node.expression,
194
243
  trigger: triggerTopic,
195
244
  ts: triggerTs
196
- };
197
-
198
- node.send([msg, null]);
245
+ }, null]);
199
246
 
200
247
  node.cacheConfig.setValue(node.outputTopic, result, {
201
248
  source: 'event-calc',
202
- expression: node.expression,
203
- inputs: Object.keys(inputDetails)
249
+ expression: node.expression
204
250
  });
205
251
 
206
252
  const resultStr = String(result);
@@ -208,72 +254,16 @@ module.exports = function(RED) {
208
254
  node.status({ fill: "green", shape: "dot", text: `= ${displayResult}` });
209
255
 
210
256
  } catch (err) {
211
- const errorMsg = {
257
+ node.send([null, {
212
258
  topic: node.outputTopic,
213
- payload: {
214
- error: err.message,
215
- expression: node.expression,
216
- context: context
217
- },
259
+ payload: { error: err.message, expression: node.expression },
218
260
  trigger: triggerTopic,
219
261
  ts: triggerTs
220
- };
221
- node.send([null, errorMsg]);
262
+ }]);
222
263
  node.status({ fill: "red", shape: "ring", text: "eval error" });
223
264
  }
224
265
  }
225
266
 
226
- // Dynamic helpers that need access to cache (created per-node)
227
- const cacheHelpers = {
228
- /**
229
- * now() - Returns current timestamp in milliseconds
230
- */
231
- now: () => Date.now(),
232
-
233
- /**
234
- * hasChanged(varName) - Returns true if the variable's current value
235
- * differs from its previous value. Returns false on first message.
236
- */
237
- hasChanged: (varName) => {
238
- const data = latestValues.get(varName);
239
- if (!data || !data.topic) return false;
240
- const entry = node.cacheConfig.getValue(data.topic);
241
- if (!entry || !entry.previous) return false;
242
- return entry.value !== entry.previous.value;
243
- },
244
-
245
- /**
246
- * timeSinceLastChange(varName) - Returns milliseconds since the value
247
- * last changed. If never changed, returns time since first message.
248
- */
249
- timeSinceLastChange: (varName) => {
250
- const data = latestValues.get(varName);
251
- if (!data || !data.topic) return 0;
252
- const entry = node.cacheConfig.getValue(data.topic);
253
- if (!entry) return 0;
254
- if (!entry.previous || entry.value === entry.previous.value) {
255
- // Value hasn't changed - return time since previous timestamp
256
- // (which is when the last different value was set)
257
- return Date.now() - (entry.previous ? entry.previous.ts : entry.ts);
258
- }
259
- // Value just changed - return time since this update
260
- return Date.now() - entry.ts;
261
- },
262
-
263
- /**
264
- * prev(varName) - Returns the previous value for a variable
265
- */
266
- prev: (varName) => {
267
- const data = latestValues.get(varName);
268
- if (!data || !data.topic) return undefined;
269
- const prev = node.cacheConfig.getPrevious(data.topic);
270
- return prev ? prev.value : undefined;
271
- }
272
- };
273
-
274
- // Subscribe to inputs
275
- const latestValues = new Map();
276
-
277
267
  for (const input of node.inputMappings) {
278
268
  const topicName = input.topic || input.pattern;
279
269
  if (!input.name || !topicName) continue;
@@ -284,8 +274,7 @@ module.exports = function(RED) {
284
274
  value: entry.value,
285
275
  ts: entry.ts
286
276
  });
287
- // Use the triggering event's timestamp
288
- tryCalculate(topic, latestValues, entry.ts);
277
+ tryCalculate(topic, entry.ts);
289
278
  });
290
279
  subscriptionIds.push(subId);
291
280
  }
@@ -301,15 +290,16 @@ module.exports = function(RED) {
301
290
  // Allow expression update via message
302
291
  if (msg.expression && typeof msg.expression === 'string') {
303
292
  node.expression = msg.expression;
293
+ compiledFn = null; // Force recompile
294
+ compiledExpression = '';
304
295
  node.status({ fill: "blue", shape: "dot", text: "expr updated" });
305
296
  }
306
297
 
307
298
  // External trigger: any incoming message triggers calculation
308
299
  if (node.externalTrigger) {
309
300
  const triggerSource = msg.topic || '_external';
310
- // Use the incoming message's timestamp or current time
311
301
  const triggerTs = msg.timestamp || Date.now();
312
- tryCalculate(triggerSource, latestValues, triggerTs);
302
+ tryCalculate(triggerSource, triggerTs);
313
303
  done();
314
304
  return;
315
305
  }
@@ -318,7 +308,7 @@ module.exports = function(RED) {
318
308
  if (msg.payload === 'recalc' || msg.topic === 'recalc') {
319
309
  if (latestValues.size > 0) {
320
310
  const triggerTs = msg.timestamp || Date.now();
321
- tryCalculate('_recalc', latestValues, triggerTs);
311
+ tryCalculate('_recalc', triggerTs);
322
312
  }
323
313
  }
324
314
 
@@ -0,0 +1,192 @@
1
+ <style>
2
+ .event-calc-white-text { fill: #ffffff !important; }
3
+ .red-ui-palette-node[data-palette-type="event-frame"] .red-ui-palette-label { color: #ffffff !important; }
4
+ </style>
5
+
6
+ <script type="text/javascript">
7
+ RED.nodes.registerType('event-frame', {
8
+ category: 'event calc',
9
+ color: '#8B6914',
10
+ defaults: {
11
+ name: { value: "" },
12
+ level: { value: "operation" },
13
+ batchName: { value: "" },
14
+ batchNameType: { value: "str" },
15
+ unit: { value: "" },
16
+ unitType: { value: "str" },
17
+ batchId: { value: "" },
18
+ batchIdType: { value: "str" },
19
+ parentLevel: { value: "" },
20
+ triggerField: { value: "payload" },
21
+ metadataField: { value: "" },
22
+ contextKey: { value: "isa88_batches" },
23
+ endChildren: { value: true }
24
+ },
25
+ inputs: 1,
26
+ outputs: 2,
27
+ outputLabels: ["start", "end"],
28
+ icon: "font-awesome/fa-industry",
29
+ label: function() {
30
+ return this.name || this.level || "event frame";
31
+ },
32
+ paletteLabel: "event frame",
33
+ labelStyle: function() { return (this.name ? "node_label_italic" : "") + " event-calc-white-text"; },
34
+ oneditprepare: function() {
35
+ var node = this;
36
+ // Typed inputs for dynamic values
37
+ $("#node-input-batchName").typedInput({
38
+ default: 'str',
39
+ types: ['str', 'msg', 'flow', 'global', 'env'],
40
+ typeField: "#node-input-batchNameType"
41
+ });
42
+ $("#node-input-unit").typedInput({
43
+ default: 'str',
44
+ types: ['str', 'msg', 'flow', 'global', 'env'],
45
+ typeField: "#node-input-unitType"
46
+ });
47
+ $("#node-input-batchId").typedInput({
48
+ default: 'str',
49
+ types: ['str', 'msg', 'flow', 'global', 'env'],
50
+ typeField: "#node-input-batchIdType"
51
+ });
52
+ }
53
+ });
54
+ </script>
55
+
56
+ <script type="text/html" data-template-name="event-frame">
57
+ <div class="form-row">
58
+ <label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
59
+ <input type="text" id="node-input-name" placeholder="Name">
60
+ </div>
61
+ <div class="form-row">
62
+ <label for="node-input-level"><i class="fa fa-sitemap"></i> ISA-88 Level</label>
63
+ <select id="node-input-level">
64
+ <option value="procedure">Procedure</option>
65
+ <option value="unit_procedure">Unit Procedure</option>
66
+ <option value="operation">Operation</option>
67
+ <option value="phase">Phase</option>
68
+ </select>
69
+ </div>
70
+ <div class="form-row">
71
+ <label for="node-input-parentLevel"><i class="fa fa-level-up"></i> Parent Level</label>
72
+ <select id="node-input-parentLevel">
73
+ <option value="">(none)</option>
74
+ <option value="procedure">Procedure</option>
75
+ <option value="unit_procedure">Unit Procedure</option>
76
+ <option value="operation">Operation</option>
77
+ <option value="phase">Phase</option>
78
+ </select>
79
+ <div class="form-tips">Links this record's <code>parent_id</code> to the currently active record at the selected level.</div>
80
+ </div>
81
+ <div class="form-row">
82
+ <label for="node-input-triggerField"><i class="fa fa-bolt"></i> Trigger Field</label>
83
+ <input type="text" id="node-input-triggerField" placeholder="payload">
84
+ <div class="form-tips">Message property that is <code>true</code> to start and <code>false</code> to end.</div>
85
+ </div>
86
+ <div class="form-row">
87
+ <label for="node-input-batchName"><i class="fa fa-pencil"></i> Batch Name</label>
88
+ <input type="text" id="node-input-batchName" placeholder="optional">
89
+ <input type="hidden" id="node-input-batchNameType">
90
+ </div>
91
+ <div class="form-row">
92
+ <label for="node-input-batchId"><i class="fa fa-barcode"></i> Batch ID</label>
93
+ <input type="text" id="node-input-batchId" placeholder="optional">
94
+ <input type="hidden" id="node-input-batchIdType">
95
+ </div>
96
+ <div class="form-row">
97
+ <label for="node-input-unit"><i class="fa fa-cube"></i> Unit</label>
98
+ <input type="text" id="node-input-unit" placeholder="optional">
99
+ <input type="hidden" id="node-input-unitType">
100
+ </div>
101
+ <div class="form-row">
102
+ <label for="node-input-metadataField"><i class="fa fa-info-circle"></i> Metadata Field</label>
103
+ <input type="text" id="node-input-metadataField" placeholder="e.g. metadata">
104
+ <div class="form-tips">Optional <code>msg</code> property containing extra metadata (string or object).</div>
105
+ </div>
106
+ <div class="form-row">
107
+ <label for="node-input-contextKey"><i class="fa fa-database"></i> Context Key</label>
108
+ <input type="text" id="node-input-contextKey" placeholder="isa88_batches">
109
+ <div class="form-tips">Global context key used to track active batch records across levels.</div>
110
+ </div>
111
+ <div class="form-row">
112
+ <label>&nbsp;</label>
113
+ <input type="checkbox" id="node-input-endChildren" style="width:auto; margin-right:5px;">
114
+ <label for="node-input-endChildren" style="width:auto;"> Auto-end children when this frame ends</label>
115
+ </div>
116
+ </script>
117
+
118
+ <script type="text/html" data-help-name="event-frame">
119
+ <p>Creates ISA-88 batch structure records with start/end tracking.</p>
120
+
121
+ <h3>ISA-88 Procedural Model</h3>
122
+ <p>The ISA-88 standard defines a hierarchical procedural model:</p>
123
+ <ul>
124
+ <li><b>Procedure</b> — top-level batch recipe</li>
125
+ <li><b>Unit Procedure</b> — sequence within a unit</li>
126
+ <li><b>Operation</b> — major processing step</li>
127
+ <li><b>Phase</b> — lowest-level action</li>
128
+ </ul>
129
+
130
+ <h3>Properties</h3>
131
+ <dl class="message-properties">
132
+ <dt>ISA-88 Level</dt>
133
+ <dd>The procedural level of this batch record.</dd>
134
+ <dt>Parent Level</dt>
135
+ <dd>Optional. Links <code>parent_id</code> to the currently active record at the selected level.
136
+ Not needed when chaining — <code>msg.frame_id</code> from a parent node is used automatically.</dd>
137
+ <dt>Trigger Field</dt>
138
+ <dd>Message property: truthy starts, falsy ends the record.</dd>
139
+ <dt>Batch Name / Batch ID / Unit</dt>
140
+ <dd>Optional fields — can be static strings, msg properties, flow/global context, or env vars.</dd>
141
+ <dt>Metadata Field</dt>
142
+ <dd>Optional msg property for extra metadata (stored as string).</dd>
143
+ <dt>Context Key</dt>
144
+ <dd>Global context key for tracking active records (default: <code>isa88_batches</code>).</dd>
145
+ <dt>Auto-end children</dt>
146
+ <dd>When enabled (default), ending this frame automatically ends all child frames whose <code>parent_id</code> matches this frame's ID. Cascades recursively.</dd>
147
+ </dl>
148
+
149
+ <h3>Inputs</h3>
150
+ <p>Any message. The trigger field determines start/end:</p>
151
+ <ul>
152
+ <li><b>Truthy</b> (and no active record): starts a new batch record</li>
153
+ <li><b>Falsy</b> (and record is active): ends the current record</li>
154
+ </ul>
155
+
156
+ <h3>Outputs</h3>
157
+ <ol>
158
+ <li><b>Start</b> — emits when a new batch record begins</li>
159
+ <li><b>End</b> — emits the completed record with <code>endtime</code> filled in</li>
160
+ </ol>
161
+
162
+ <h3>Output Record</h3>
163
+ <pre>{
164
+ "id": "auto-generated UUID",
165
+ "starttime": "2024-01-15T10:30:00.000Z",
166
+ "endtime": "9999-12-31T23:59:59.000Z",
167
+ "name": "Mixing",
168
+ "parent_id": "parent-uuid-or-empty",
169
+ "level": "operation",
170
+ "state": "complete",
171
+ "batch_id": "BATCH-001",
172
+ "unit": "Reactor-1",
173
+ "metadata": ""
174
+ }</pre>
175
+
176
+ <h3>Chaining</h3>
177
+ <p>Wire the <b>Start</b> output of a parent frame to the input of a child frame.
178
+ The start message carries <code>msg.frame_id</code> which the child automatically
179
+ uses as its <code>parent_id</code>. No need to set Parent Level when chaining.</p>
180
+ <pre>
181
+ [Procedure] ──start──► [Operation] ──start──► [Phase]
182
+ </pre>
183
+ <p>When <b>Auto-end children</b> is enabled, ending the Procedure will automatically
184
+ end the Operation, which in turn ends the Phase (recursive cascade).</p>
185
+
186
+ <h3>Details</h3>
187
+ <p>Use one node per ISA-88 level. Link them by wiring start outputs to child inputs,
188
+ or set <b>Parent Level</b> for lookup-based linking.</p>
189
+
190
+ <p>Multiple instances sharing the same <b>Context Key</b> can coordinate
191
+ across the hierarchy.</p>
192
+ </script>