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.
@@ -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
 
@@ -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, latestValues, triggerTs) {
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 (const input of node.inputMappings) {
111
- if (!latestValues.has(input.name)) {
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
- const context = {};
122
- const inputDetails = {};
123
- const missingInputs = [];
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
- context[input.name] = data.value;
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
- context[input.name] = undefined;
136
- missingInputs.push(input.name);
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 allParams = { ...helpers, ...context };
150
- const paramNames = Object.keys(allParams);
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
- const errorMsg = {
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
- const msg = {
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
- const errorMsg = {
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
- // Use the triggering event's timestamp
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, latestValues, triggerTs);
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', latestValues, triggerTs);
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>&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>