node-red-contrib-event-calc 3.3.3 → 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.
- package/README.md +127 -55
- package/examples/alarm-simulation-example.json +288 -0
- package/examples/change-detection-example.json +448 -0
- package/examples/isa88-batch-example.json +278 -0
- package/examples/opcua-alarm-example.json +516 -0
- package/examples/test-flows.json +723 -0
- package/examples/throughput-test.json +293 -0
- package/nodes/event-alarm.html +252 -0
- package/nodes/event-alarm.js +292 -0
- package/nodes/event-cache.js +84 -33
- package/nodes/event-calc.js +132 -77
- package/nodes/event-flatten.html +53 -0
- package/nodes/event-flatten.js +25 -0
- package/nodes/event-frame.html +192 -0
- package/nodes/event-frame.js +246 -0
- package/nodes/simple-frame.html +125 -0
- package/nodes/simple-frame.js +126 -0
- package/package.json +6 -2
- package/node-red-contrib-event-calc-3.3.2.tgz +0 -0
- package/nul +0 -0
- package/playwright.config.js +0 -22
- package/tests/external-trigger.spec.js +0 -141
package/nodes/event-calc.js
CHANGED
|
@@ -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
|
-
* -
|
|
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:
|
|
20
|
-
sqrt:
|
|
21
|
-
pow:
|
|
22
|
-
log:
|
|
23
|
-
log10:
|
|
24
|
-
exp:
|
|
25
|
-
floor:
|
|
26
|
-
ceil:
|
|
27
|
-
sin:
|
|
28
|
-
cos:
|
|
29
|
-
tan:
|
|
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
|
|
|
@@ -55,8 +55,30 @@ module.exports = function(RED) {
|
|
|
55
55
|
|
|
56
56
|
// Delta/change detection (returns difference)
|
|
57
57
|
delta: (current, previous) => current - previous,
|
|
58
|
-
pctChange: (current, previous) => previous !== 0 ? ((current - previous) / previous) * 100 : 0
|
|
58
|
+
pctChange: (current, previous) => previous !== 0 ? ((current - previous) / previous) * 100 : 0,
|
|
59
|
+
|
|
60
|
+
// Date/time helpers (all based on local time)
|
|
61
|
+
hour: () => new Date().getHours(),
|
|
62
|
+
minute: () => new Date().getMinutes(),
|
|
63
|
+
second: () => new Date().getSeconds(),
|
|
64
|
+
day: () => new Date().getDay(), // 0=Sun, 1=Mon, ..., 6=Sat
|
|
65
|
+
dayOfMonth: () => new Date().getDate(),
|
|
66
|
+
month: () => new Date().getMonth() + 1, // 1-12
|
|
67
|
+
year: () => new Date().getFullYear(),
|
|
68
|
+
isWeekday: () => { const d = new Date().getDay(); return d >= 1 && d <= 5; },
|
|
69
|
+
isWeekend: () => { const d = new Date().getDay(); return d === 0 || d === 6; },
|
|
70
|
+
hoursBetween: (startHour, endHour) => {
|
|
71
|
+
const h = new Date().getHours();
|
|
72
|
+
return startHour <= endHour
|
|
73
|
+
? h >= startHour && h < endHour
|
|
74
|
+
: h >= startHour || h < endHour; // wraps midnight
|
|
75
|
+
}
|
|
59
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
|
+
|
|
60
82
|
function EventCalcNode(config) {
|
|
61
83
|
RED.nodes.createNode(this, config);
|
|
62
84
|
const node = this;
|
|
@@ -94,21 +116,89 @@ module.exports = function(RED) {
|
|
|
94
116
|
}
|
|
95
117
|
}
|
|
96
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
|
+
|
|
97
190
|
/**
|
|
98
191
|
* Attempt to calculate and output result
|
|
99
|
-
* @param {string} triggerTopic - Topic that triggered the calculation
|
|
100
|
-
* @param {Map} latestValues - Current cached values
|
|
101
|
-
* @param {number} triggerTs - Timestamp of the triggering event
|
|
102
192
|
*/
|
|
103
|
-
function tryCalculate(triggerTopic,
|
|
193
|
+
function tryCalculate(triggerTopic, triggerTs) {
|
|
104
194
|
// Ignore updates triggered by our own output
|
|
105
195
|
if (triggerTopic === node.outputTopic) {
|
|
106
196
|
return;
|
|
107
197
|
}
|
|
108
198
|
|
|
109
199
|
if (node.triggerOn === 'all') {
|
|
110
|
-
for (
|
|
111
|
-
if (!latestValues.has(
|
|
200
|
+
for (let i = 0; i < inputNames.length; i++) {
|
|
201
|
+
if (!latestValues.has(inputNames[i])) {
|
|
112
202
|
return;
|
|
113
203
|
}
|
|
114
204
|
}
|
|
@@ -118,72 +208,45 @@ module.exports = function(RED) {
|
|
|
118
208
|
return;
|
|
119
209
|
}
|
|
120
210
|
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
for (const input of node.inputMappings) {
|
|
126
|
-
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]);
|
|
127
215
|
if (data && data.value !== undefined && data.value !== null) {
|
|
128
|
-
|
|
129
|
-
inputDetails[input.name] = {
|
|
130
|
-
topic: data.topic,
|
|
131
|
-
value: data.value,
|
|
132
|
-
ts: data.ts
|
|
133
|
-
};
|
|
216
|
+
allParamValues[fixedCount + i] = data.value;
|
|
134
217
|
} else {
|
|
135
|
-
|
|
136
|
-
|
|
218
|
+
allParamValues[fixedCount + i] = undefined;
|
|
219
|
+
hasAllInputs = false;
|
|
137
220
|
}
|
|
138
221
|
}
|
|
139
222
|
|
|
140
|
-
// Build topics mapping: variable name -> topic
|
|
141
|
-
const topics = { _output: node.outputTopic };
|
|
142
|
-
const timestamps = {};
|
|
143
|
-
for (const [name, details] of Object.entries(inputDetails)) {
|
|
144
|
-
topics[name] = details.topic;
|
|
145
|
-
timestamps[name] = details.ts;
|
|
146
|
-
}
|
|
147
|
-
|
|
148
223
|
try {
|
|
149
|
-
const
|
|
150
|
-
const
|
|
151
|
-
const paramValues = Object.values(allParams);
|
|
152
|
-
|
|
153
|
-
const fn = new Function(...paramNames, `return ${node.expression};`);
|
|
154
|
-
const result = fn(...paramValues);
|
|
224
|
+
const fn = compileExpression(node.expression);
|
|
225
|
+
const result = fn(...allParamValues);
|
|
155
226
|
|
|
156
227
|
// Check for NaN or invalid result
|
|
157
228
|
if (typeof result === 'number' && isNaN(result)) {
|
|
158
|
-
|
|
229
|
+
node.send([null, {
|
|
159
230
|
topic: node.outputTopic,
|
|
160
|
-
payload: {
|
|
161
|
-
error: 'Expression resulted in NaN',
|
|
162
|
-
missingInputs: missingInputs,
|
|
163
|
-
expression: node.expression
|
|
164
|
-
},
|
|
231
|
+
payload: { error: 'Expression resulted in NaN', expression: node.expression },
|
|
165
232
|
trigger: triggerTopic,
|
|
166
233
|
ts: triggerTs
|
|
167
|
-
};
|
|
168
|
-
node.send([null, errorMsg]);
|
|
234
|
+
}]);
|
|
169
235
|
node.status({ fill: "yellow", shape: "ring", text: "NaN" });
|
|
170
236
|
return;
|
|
171
237
|
}
|
|
172
238
|
|
|
173
|
-
|
|
239
|
+
node.send([{
|
|
174
240
|
topic: node.outputTopic,
|
|
175
241
|
payload: result,
|
|
176
242
|
expression: node.expression,
|
|
177
243
|
trigger: triggerTopic,
|
|
178
244
|
ts: triggerTs
|
|
179
|
-
};
|
|
180
|
-
|
|
181
|
-
node.send([msg, null]);
|
|
245
|
+
}, null]);
|
|
182
246
|
|
|
183
247
|
node.cacheConfig.setValue(node.outputTopic, result, {
|
|
184
248
|
source: 'event-calc',
|
|
185
|
-
expression: node.expression
|
|
186
|
-
inputs: Object.keys(inputDetails)
|
|
249
|
+
expression: node.expression
|
|
187
250
|
});
|
|
188
251
|
|
|
189
252
|
const resultStr = String(result);
|
|
@@ -191,24 +254,16 @@ module.exports = function(RED) {
|
|
|
191
254
|
node.status({ fill: "green", shape: "dot", text: `= ${displayResult}` });
|
|
192
255
|
|
|
193
256
|
} catch (err) {
|
|
194
|
-
|
|
257
|
+
node.send([null, {
|
|
195
258
|
topic: node.outputTopic,
|
|
196
|
-
payload: {
|
|
197
|
-
error: err.message,
|
|
198
|
-
expression: node.expression,
|
|
199
|
-
context: context
|
|
200
|
-
},
|
|
259
|
+
payload: { error: err.message, expression: node.expression },
|
|
201
260
|
trigger: triggerTopic,
|
|
202
261
|
ts: triggerTs
|
|
203
|
-
};
|
|
204
|
-
node.send([null, errorMsg]);
|
|
262
|
+
}]);
|
|
205
263
|
node.status({ fill: "red", shape: "ring", text: "eval error" });
|
|
206
264
|
}
|
|
207
265
|
}
|
|
208
266
|
|
|
209
|
-
// Subscribe to inputs
|
|
210
|
-
const latestValues = new Map();
|
|
211
|
-
|
|
212
267
|
for (const input of node.inputMappings) {
|
|
213
268
|
const topicName = input.topic || input.pattern;
|
|
214
269
|
if (!input.name || !topicName) continue;
|
|
@@ -219,8 +274,7 @@ module.exports = function(RED) {
|
|
|
219
274
|
value: entry.value,
|
|
220
275
|
ts: entry.ts
|
|
221
276
|
});
|
|
222
|
-
|
|
223
|
-
tryCalculate(topic, latestValues, entry.ts);
|
|
277
|
+
tryCalculate(topic, entry.ts);
|
|
224
278
|
});
|
|
225
279
|
subscriptionIds.push(subId);
|
|
226
280
|
}
|
|
@@ -236,15 +290,16 @@ module.exports = function(RED) {
|
|
|
236
290
|
// Allow expression update via message
|
|
237
291
|
if (msg.expression && typeof msg.expression === 'string') {
|
|
238
292
|
node.expression = msg.expression;
|
|
293
|
+
compiledFn = null; // Force recompile
|
|
294
|
+
compiledExpression = '';
|
|
239
295
|
node.status({ fill: "blue", shape: "dot", text: "expr updated" });
|
|
240
296
|
}
|
|
241
297
|
|
|
242
298
|
// External trigger: any incoming message triggers calculation
|
|
243
299
|
if (node.externalTrigger) {
|
|
244
300
|
const triggerSource = msg.topic || '_external';
|
|
245
|
-
// Use the incoming message's timestamp or current time
|
|
246
301
|
const triggerTs = msg.timestamp || Date.now();
|
|
247
|
-
tryCalculate(triggerSource,
|
|
302
|
+
tryCalculate(triggerSource, triggerTs);
|
|
248
303
|
done();
|
|
249
304
|
return;
|
|
250
305
|
}
|
|
@@ -253,7 +308,7 @@ module.exports = function(RED) {
|
|
|
253
308
|
if (msg.payload === 'recalc' || msg.topic === 'recalc') {
|
|
254
309
|
if (latestValues.size > 0) {
|
|
255
310
|
const triggerTs = msg.timestamp || Date.now();
|
|
256
|
-
tryCalculate('_recalc',
|
|
311
|
+
tryCalculate('_recalc', triggerTs);
|
|
257
312
|
}
|
|
258
313
|
}
|
|
259
314
|
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
<style>
|
|
2
|
+
.event-calc-white-text { fill: #ffffff !important; }
|
|
3
|
+
.red-ui-palette-node[data-palette-type="event-flatten"] .red-ui-palette-label { color: #ffffff !important; }
|
|
4
|
+
</style>
|
|
5
|
+
|
|
6
|
+
<script type="text/javascript">
|
|
7
|
+
RED.nodes.registerType('event-flatten', {
|
|
8
|
+
category: 'event calc',
|
|
9
|
+
color: '#758467',
|
|
10
|
+
defaults: {
|
|
11
|
+
name: { value: "" }
|
|
12
|
+
},
|
|
13
|
+
inputs: 1,
|
|
14
|
+
outputs: 1,
|
|
15
|
+
icon: "font-awesome/fa-expand",
|
|
16
|
+
label: function() {
|
|
17
|
+
return this.name || "event flatten";
|
|
18
|
+
},
|
|
19
|
+
paletteLabel: "event flatten",
|
|
20
|
+
labelStyle: function() { return (this.name ? "node_label_italic" : "") + " event-calc-white-text"; }
|
|
21
|
+
});
|
|
22
|
+
</script>
|
|
23
|
+
|
|
24
|
+
<script type="text/html" data-template-name="event-flatten">
|
|
25
|
+
<div class="form-row">
|
|
26
|
+
<label for="node-input-name"><i class="fa fa-tag"></i> Name</label>
|
|
27
|
+
<input type="text" id="node-input-name" placeholder="Name">
|
|
28
|
+
</div>
|
|
29
|
+
</script>
|
|
30
|
+
|
|
31
|
+
<script type="text/html" data-help-name="event-flatten">
|
|
32
|
+
<p>Flattens <code>msg.payload</code> into the top-level message object.</p>
|
|
33
|
+
|
|
34
|
+
<h3>Behavior</h3>
|
|
35
|
+
<p>Takes all properties from <code>msg.payload</code> (must be a plain object) and assigns them
|
|
36
|
+
directly onto <code>msg</code>, then removes <code>msg.payload</code>.</p>
|
|
37
|
+
|
|
38
|
+
<h3>Example</h3>
|
|
39
|
+
<pre>
|
|
40
|
+
Input:
|
|
41
|
+
msg.payload = { topic: "sensor/temp", value: 25.5, timestamp: "2026-02-06T..." }
|
|
42
|
+
|
|
43
|
+
Output:
|
|
44
|
+
msg.topic = "sensor/temp"
|
|
45
|
+
msg.value = 25.5
|
|
46
|
+
msg.timestamp = "2026-02-06T..."</pre>
|
|
47
|
+
|
|
48
|
+
<h3>Status</h3>
|
|
49
|
+
<ul>
|
|
50
|
+
<li><b>Green</b> - Successfully flattened, shows number of fields merged</li>
|
|
51
|
+
<li><b>Yellow</b> - Payload was not a plain object, message passed through unchanged</li>
|
|
52
|
+
</ul>
|
|
53
|
+
</script>
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
module.exports = function(RED) {
|
|
2
|
+
function EventFlattenNode(config) {
|
|
3
|
+
RED.nodes.createNode(this, config);
|
|
4
|
+
const node = this;
|
|
5
|
+
|
|
6
|
+
node.on('input', function(msg, send, done) {
|
|
7
|
+
send = send || function() { node.send.apply(node, arguments); };
|
|
8
|
+
done = done || function(err) { if (err) node.error(err, msg); };
|
|
9
|
+
|
|
10
|
+
const payload = msg.payload;
|
|
11
|
+
if (payload && typeof payload === 'object' && !Array.isArray(payload)) {
|
|
12
|
+
delete msg.payload;
|
|
13
|
+
Object.assign(msg, payload);
|
|
14
|
+
node.status({ fill: "green", shape: "dot", text: `${Object.keys(payload).length} fields` });
|
|
15
|
+
} else {
|
|
16
|
+
node.status({ fill: "yellow", shape: "ring", text: "payload not an object" });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
send(msg);
|
|
20
|
+
done();
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
RED.nodes.registerType("event-flatten", EventFlattenNode);
|
|
25
|
+
};
|
|
@@ -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> </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>
|