@bldgblocks/node-red-contrib-control 0.1.27 → 0.1.29

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.
@@ -1,22 +1,15 @@
1
1
  module.exports = function(RED) {
2
+ const utils = require('./utils')(RED);
3
+
2
4
  function PIDBlockNode(config) {
3
5
  RED.nodes.createNode(this, config);
6
+
4
7
  const node = this;
5
8
 
6
- // Initialize runtime state
9
+ // Initialize runtime state
7
10
  node.runtime = {
8
- name: config.name || "",
9
- kp: parseFloat(config.kp) || 0,
10
- ki: parseFloat(config.ki) || 0,
11
- kd: parseFloat(config.kd) || 0,
12
- setpoint: parseFloat(config.setpoint) || 0,
13
- deadband: parseFloat(config.deadband) || 0,
14
- dbBehavior: config.dbBehavior || "ReturnToZero",
15
- outMin: config.outMin ? parseFloat(config.outMin) : null,
16
- outMax: config.outMax ? parseFloat(config.outMax) : null,
17
- maxChange: parseFloat(config.maxChange) || 0,
18
- directAction: !!config.directAction,
19
- run: config.run !== false,
11
+ name: config.name,
12
+ dbBehavior: config.dbBehavior,
20
13
  errorSum: 0,
21
14
  lastError: 0,
22
15
  lastDError: 0,
@@ -25,36 +18,35 @@ module.exports = function(RED) {
25
18
  tuneMode: false,
26
19
  tuneData: { oscillations: [], lastPeak: null, lastTrough: null, Ku: 0, Tu: 0 }
27
20
  };
28
-
29
- // Validate initial config
30
- if (isNaN(node.runtime.kp) || isNaN(node.runtime.ki) || isNaN(node.runtime.kd) ||
31
- isNaN(node.runtime.setpoint) || isNaN(node.runtime.deadband) || isNaN(node.runtime.maxChange) ||
32
- !isFinite(node.runtime.kp) || !isFinite(node.runtime.ki) || !isFinite(node.runtime.kd) ||
33
- !isFinite(node.runtime.setpoint) || !isFinite(node.runtime.deadband) || !isFinite(node.runtime.maxChange)) {
34
- node.status({ fill: "red", shape: "ring", text: "invalid config" });
35
- node.runtime.kp = node.runtime.ki = node.runtime.kd = node.runtime.setpoint = node.runtime.deadband = node.runtime.maxChange = 0;
36
- }
37
- if (node.runtime.deadband < 0 || node.runtime.maxChange < 0) {
38
- node.status({ fill: "red", shape: "ring", text: "invalid deadband or maxChange" });
39
- node.runtime.deadband = node.runtime.maxChange = 0;
40
- }
41
- if (node.runtime.outMin != null && node.runtime.outMax != null && node.runtime.outMax <= node.runtime.outMin) {
42
- node.status({ fill: "red", shape: "ring", text: "invalid output range" });
43
- node.runtime.outMin = node.runtime.outMax = null;
44
- }
45
- if (!["ReturnToZero", "HoldLastResult"].includes(node.runtime.dbBehavior)) {
46
- node.status({ fill: "red", shape: "ring", text: "invalid dbBehavior" });
47
- node.runtime.dbBehavior = "ReturnToZero";
21
+
22
+ try {
23
+ node.runtime.kp = parseFloat(RED.util.evaluateNodeProperty( config.kp, config.kpType, node ));
24
+ node.runtime.ki = parseFloat(RED.util.evaluateNodeProperty( config.ki, config.kiType, node ));
25
+ node.runtime.kd = parseFloat(RED.util.evaluateNodeProperty( config.kd, config.kdType, node ));
26
+ node.runtime.setpoint = parseFloat(RED.util.evaluateNodeProperty( config.setpoint, config.setpointType, node ));
27
+ node.runtime.deadband = parseFloat(RED.util.evaluateNodeProperty( config.deadband, config.deadbandType, node ));
28
+ node.runtime.outMin = parseFloat(RED.util.evaluateNodeProperty( config.outMin, config.outMinType, node ));
29
+ node.runtime.outMax = parseFloat(RED.util.evaluateNodeProperty( config.outMax, config.outMaxType, node ));
30
+ node.runtime.maxChange = parseFloat(RED.util.evaluateNodeProperty( config.maxChange, config.maxChangeType, node ));
31
+ node.runtime.run = RED.util.evaluateNodeProperty( config.run, config.runType, node ) === true;
32
+ } catch (err) {
33
+ node.error(`Error evaluating properties: ${err.message}`);
48
34
  }
49
35
 
50
36
  // Initialize internal variables
51
- let storekp = node.runtime.kp;
52
- let storeki = node.runtime.ki;
53
- let storemin = node.runtime.outMin;
54
- let storemax = node.runtime.outMax;
55
- let kpkiConst = node.runtime.kp * node.runtime.ki;
56
- let minInt = kpkiConst === 0 ? 0 : (node.runtime.outMin || -Infinity) * kpkiConst;
57
- let maxInt = kpkiConst === 0 ? 0 : (node.runtime.outMax || Infinity) * kpkiConst;
37
+ let storekp = parseFloat(config.kp) || 0;
38
+ let storeki = parseFloat(config.ki) || 0;
39
+ let storekd = parseFloat(config.kd) || 0;
40
+ let storesetpoint = parseFloat(config.setpoint) || 0;
41
+ let storedeadband = parseFloat(config.deadband) || 0;
42
+ let storeOutMin = config.outMin ? parseFloat(config.outMin) : null;
43
+ let storeOutMax = config.outMax ? parseFloat(config.outMax) : null;
44
+ let storemaxChange = parseFloat(config.maxChange) || 0;
45
+ let storerun = !!config.run; // convert to boolean
46
+
47
+ let kpkiConst = storekp * storeki;
48
+ let minInt = kpkiConst === 0 ? 0 : (storeOutMin || -Infinity) * kpkiConst;
49
+ let maxInt = kpkiConst === 0 ? 0 : (storeOutMax || Infinity) * kpkiConst;
58
50
  let lastOutput = null;
59
51
 
60
52
  node.on("input", function(msg, send, done) {
@@ -67,6 +59,60 @@ module.exports = function(RED) {
67
59
  return;
68
60
  }
69
61
 
62
+ // Evaluate typed-input properties
63
+ try {
64
+ if (utils.requiresEvaluation(config.kpType)) {
65
+ node.runtime.kp = parseFloat(RED.util.evaluateNodeProperty( config.kp, config.kpType, node, msg ));
66
+ }
67
+ if (utils.requiresEvaluation(config.kiType)) {
68
+ node.runtime.ki = parseFloat(RED.util.evaluateNodeProperty( config.ki, config.kiType, node, msg ));
69
+ }
70
+ if (utils.requiresEvaluation(config.kdType)) {
71
+ node.runtime.kd = parseFloat(RED.util.evaluateNodeProperty( config.kd, config.kdType, node, msg ));
72
+ }
73
+ if (utils.requiresEvaluation(config.setpointType)) {
74
+ node.runtime.setpoint = parseFloat(RED.util.evaluateNodeProperty( config.setpoint, config.setpointType, node, msg ));
75
+ }
76
+ if (utils.requiresEvaluation(config.deadbandType)) {
77
+ node.runtime.deadband = parseFloat(RED.util.evaluateNodeProperty( config.deadband, config.deadbandType, node, msg ));
78
+ }
79
+ if (utils.requiresEvaluation(config.outMinType)) {
80
+ node.runtime.outMin = parseFloat(RED.util.evaluateNodeProperty( config.outMin, config.outMinType, node, msg ));
81
+ }
82
+ if (utils.requiresEvaluation(config.outMaxType)) {
83
+ node.runtime.outMax = parseFloat(RED.util.evaluateNodeProperty( config.outMax, config.outMaxType, node, msg ));
84
+ }
85
+ if (utils.requiresEvaluation(config.maxChangeType)) {
86
+ node.runtime.maxChange = parseFloat(RED.util.evaluateNodeProperty( config.maxChange, config.maxChangeType, node, msg ));
87
+ }
88
+ if (utils.requiresEvaluation(config.runType)) {
89
+ node.runtime.run = RED.util.evaluateNodeProperty( config.run, config.runType, node, msg ) === true;
90
+ }
91
+ } catch (err) {
92
+ node.error(`Error evaluating properties: ${err.message}`);
93
+ }
94
+
95
+ // Validate config
96
+ if (isNaN(node.runtime.kp) || isNaN(node.runtime.ki) || isNaN(node.runtime.kd) ||
97
+ isNaN(node.runtime.setpoint) || isNaN(node.runtime.deadband) || isNaN(node.runtime.maxChange) ||
98
+ !isFinite(node.runtime.kp) || !isFinite(node.runtime.ki) || !isFinite(node.runtime.kd) ||
99
+ !isFinite(node.runtime.setpoint) || !isFinite(node.runtime.deadband) || !isFinite(node.runtime.maxChange)) {
100
+ node.status({ fill: "red", shape: "ring", text: "invalid config" });
101
+ node.runtime.kp = node.runtime.ki = node.runtime.kd = node.runtime.setpoint = node.runtime.deadband = node.runtime.maxChange = 0;
102
+ }
103
+ if (node.runtime.deadband < 0 || node.runtime.maxChange < 0) {
104
+ node.status({ fill: "red", shape: "ring", text: "invalid deadband or maxChange" });
105
+ node.runtime.deadband = node.runtime.maxChange = 0;
106
+ }
107
+ if (node.runtime.outMin != null && node.runtime.outMax != null && node.runtime.outMax <= node.runtime.outMin) {
108
+ node.status({ fill: "red", shape: "ring", text: "invalid output range" });
109
+ node.runtime.outMin = node.runtime.outMax = null;
110
+ }
111
+ if (!["ReturnToZero", "HoldLastResult"].includes(node.runtime.dbBehavior)) {
112
+ node.status({ fill: "red", shape: "ring", text: "invalid dbBehavior" });
113
+ node.runtime.dbBehavior = "ReturnToZero";
114
+ }
115
+
70
116
  // Handle context updates
71
117
  if (msg.hasOwnProperty("context")) {
72
118
  if (!msg.hasOwnProperty("payload")) {
@@ -156,14 +202,14 @@ module.exports = function(RED) {
156
202
  }
157
203
 
158
204
  if (!msg.hasOwnProperty("payload")) {
159
- node.status({ fill: "red", shape: "ring", text: "missing input" });
205
+ node.status({ fill: "red", shape: "ring", text: "missing payload" });
160
206
  if (done) done();
161
207
  return;
162
208
  }
163
209
 
164
210
  const input = parseFloat(msg.payload);
165
211
  if (isNaN(input) || !isFinite(input)) {
166
- node.status({ fill: "red", shape: "ring", text: "invalid input" });
212
+ node.status({ fill: "red", shape: "ring", text: "invalid payload" });
167
213
  if (done) done();
168
214
  return;
169
215
  }
@@ -173,7 +219,18 @@ module.exports = function(RED) {
173
219
  let interval = (currentTime - node.runtime.lastTime) / 1000; // Seconds
174
220
  node.runtime.lastTime = currentTime;
175
221
 
176
- let outputMsg = { payload: 0, diagnostics: {} };
222
+ let outputMsg = { payload: 0 };
223
+ outputMsg.diagnostics = {
224
+ setpoint: node.runtime.setpoint,
225
+ interval,
226
+ lastOutput,
227
+ run: node.runtime.run,
228
+ directAction: node.runtime.directAction,
229
+ kp: node.runtime.kp,
230
+ ki: node.runtime.ki,
231
+ kd: node.runtime.kd
232
+ };
233
+
177
234
  if (!node.runtime.run || interval <= 0 || node.runtime.kp === 0) {
178
235
  if (lastOutput !== 0) {
179
236
  lastOutput = 0;
@@ -182,7 +239,6 @@ module.exports = function(RED) {
182
239
  shape: "dot",
183
240
  text: `in: ${input.toFixed(2)}, out: 0.00, setpoint: ${node.runtime.setpoint.toFixed(2)}`
184
241
  });
185
- send(outputMsg);
186
242
  } else {
187
243
  node.status({
188
244
  fill: "blue",
@@ -190,6 +246,7 @@ module.exports = function(RED) {
190
246
  text: `in: ${input.toFixed(2)}, out: 0.00, setpoint: ${node.runtime.setpoint.toFixed(2)}`
191
247
  });
192
248
  }
249
+ send(outputMsg);
193
250
  if (done) done();
194
251
  return;
195
252
  }
@@ -218,7 +275,7 @@ module.exports = function(RED) {
218
275
  }
219
276
 
220
277
  // Update internal constraints
221
- if (node.runtime.kp !== storekp || node.runtime.ki !== storeki || node.runtime.outMin !== storemin || node.runtime.outMax !== storemax) {
278
+ if (node.runtime.kp !== storekp || node.runtime.ki !== storeki || node.runtime.outMin !== storeOutMin || node.runtime.outMax !== storeOutMax) {
222
279
  if (node.runtime.kp !== storekp && node.runtime.kp !== 0 && storekp !== 0) {
223
280
  node.runtime.errorSum = node.runtime.errorSum * storekp / node.runtime.kp;
224
281
  }
@@ -230,8 +287,8 @@ module.exports = function(RED) {
230
287
  maxInt = kpkiConst === 0 ? 0 : (node.runtime.outMax || Infinity) * kpkiConst;
231
288
  storekp = node.runtime.kp;
232
289
  storeki = node.runtime.ki;
233
- storemin = node.runtime.outMin;
234
- storemax = node.runtime.outMax;
290
+ storeOutMin = node.runtime.outMin;
291
+ storeOutMax = node.runtime.outMax;
235
292
  }
236
293
 
237
294
  // Calculate error
@@ -272,6 +329,7 @@ module.exports = function(RED) {
272
329
  shape: "dot",
273
330
  text: `tune: completed, Kp=${node.runtime.kp.toFixed(2)}, Ki=${node.runtime.ki.toFixed(2)}, Kd=${node.runtime.kd.toFixed(2)}`
274
331
  });
332
+
275
333
  send(outputMsg);
276
334
  if (done) done();
277
335
  return;
@@ -302,7 +360,7 @@ module.exports = function(RED) {
302
360
  // Output calculation
303
361
  let pv = pGain + intGain + dGain;
304
362
  //if (node.runtime.directAction) pv = -pv;
305
- pv = Math.min(Math.max(pv, node.runtime.outMin || -Infinity), node.runtime.outMax || Infinity);
363
+ pv = Math.min(Math.max(pv, node.runtime.outMin), node.runtime.outMax);
306
364
 
307
365
  // Rate of change limit
308
366
  if (node.runtime.maxChange !== 0) {
@@ -316,7 +374,18 @@ module.exports = function(RED) {
316
374
  }
317
375
 
318
376
  outputMsg.payload = node.runtime.result;
319
- outputMsg.diagnostics = { pGain, intGain, dGain, error, errorSum: node.runtime.errorSum };
377
+ outputMsg.diagnostics = {
378
+ pGain,
379
+ intGain,
380
+ dGain,
381
+ error,
382
+ errorSum: node.runtime.errorSum,
383
+ run: node.runtime.run,
384
+ directAction: node.runtime.directAction,
385
+ kp: node.runtime.kp,
386
+ ki: node.runtime.ki,
387
+ kd: node.runtime.kd
388
+ };
320
389
 
321
390
  const outputChanged = !lastOutput || lastOutput !== outputMsg.payload;
322
391
  if (outputChanged) {
@@ -326,7 +395,6 @@ module.exports = function(RED) {
326
395
  shape: "dot",
327
396
  text: `in: ${input.toFixed(2)}, out: ${node.runtime.result.toFixed(2)}, setpoint: ${node.runtime.setpoint.toFixed(2)}`
328
397
  });
329
- send(outputMsg);
330
398
  } else {
331
399
  node.status({
332
400
  fill: "blue",
@@ -335,73 +403,15 @@ module.exports = function(RED) {
335
403
  });
336
404
  }
337
405
 
406
+ send(outputMsg);
407
+
338
408
  if (done) done();
339
409
  });
340
410
 
341
411
  node.on("close", function(done) {
342
- node.runtime = {
343
- name: config.name || "",
344
- kp: parseFloat(config.kp) || 0,
345
- ki: parseFloat(config.ki) || 0,
346
- kd: parseFloat(config.kd) || 0,
347
- setpoint: parseFloat(config.setpoint) || 0,
348
- deadband: parseFloat(config.deadband) || 0,
349
- dbBehavior: config.dbBehavior || "ReturnToZero",
350
- outMin: config.outMin ? parseFloat(config.outMin) : null,
351
- outMax: config.outMax ? parseFloat(config.outMax) : null,
352
- maxChange: parseFloat(config.maxChange) || 0,
353
- directAction: !!config.directAction,
354
- run: config.run !== false,
355
- errorSum: 0,
356
- lastError: 0,
357
- lastDError: 0,
358
- result: 0,
359
- lastTime: Date.now(),
360
- tuneMode: false,
361
- tuneData: { oscillations: [], lastPeak: null, lastTrough: null, Ku: 0, Tu: 0 }
362
- };
363
- if (isNaN(node.runtime.kp) || isNaN(node.runtime.ki) || isNaN(node.runtime.kd) ||
364
- isNaN(node.runtime.setpoint) || isNaN(node.runtime.deadband) || isNaN(node.runtime.maxChange) ||
365
- !isFinite(node.runtime.kp) || !isFinite(node.runtime.ki) || !isFinite(node.runtime.kd) ||
366
- !isFinite(node.runtime.setpoint) || !isFinite(node.runtime.deadband) || !isFinite(node.runtime.maxChange)) {
367
- node.runtime.kp = node.runtime.ki = node.runtime.kd = node.runtime.setpoint = node.runtime.deadband = node.runtime.maxChange = 0;
368
- }
369
- if (node.runtime.deadband < 0 || node.runtime.maxChange < 0) {
370
- node.runtime.deadband = node.runtime.maxChange = 0;
371
- }
372
- if (node.runtime.outMin != null && node.runtime.outMax != null && node.runtime.outMax <= node.runtime.outMin) {
373
- node.runtime.outMin = node.runtime.outMax = null;
374
- }
375
- if (!["ReturnToZero", "HoldLastResult"].includes(node.runtime.dbBehavior)) {
376
- node.runtime.dbBehavior = "ReturnToZero";
377
- }
378
- node.status({});
379
412
  done();
380
413
  });
381
414
  }
382
415
 
383
416
  RED.nodes.registerType("pid-block", PIDBlockNode);
384
-
385
- // Serve runtime state for editor
386
- RED.httpAdmin.get("/pid-block-runtime/:id", RED.auth.needsPermission("pid-block.read"), function(req, res) {
387
- const node = RED.nodes.getNode(req.params.id);
388
- if (node && node.type === "pid-block") {
389
- res.json({
390
- name: node.runtime.name,
391
- kp: node.runtime.kp,
392
- ki: node.runtime.ki,
393
- kd: node.runtime.kd,
394
- setpoint: node.runtime.setpoint,
395
- deadband: node.runtime.deadband,
396
- dbBehavior: node.runtime.dbBehavior,
397
- outMin: node.runtime.outMin,
398
- outMax: node.runtime.outMax,
399
- maxChange: node.runtime.maxChange,
400
- directAction: node.runtime.directAction,
401
- run: node.runtime.run
402
- });
403
- } else {
404
- res.status(404).json({ error: "Node not found" });
405
- }
406
- });
407
417
  };
@@ -0,0 +1,110 @@
1
+ <script type="text/html" data-template-name="rate-of-change-block">
2
+ <div class="form-row">
3
+ <label for="node-input-name" title="Display name shown on the canvas"><i class="fa fa-tag"></i> Name</label>
4
+ <input type="text" id="node-input-name" placeholder="Name">
5
+ </div>
6
+ <div class="form-row">
7
+ <label for="node-input-sampleSize" title="Number of samples to track (minimum 2)"><i class="fa fa-list-ol"></i> Sample Size</label>
8
+ <input type="number" id="node-input-sampleSize" placeholder="10" min="2" step="1">
9
+ </div>
10
+ <div class="form-row">
11
+ <label for="node-input-units" title="Time units for rate calculation"><i class="fa fa-clock-o"></i> Rate Units</label>
12
+ <select id="node-input-units">
13
+ <option value="seconds">Seconds</option>
14
+ <option value="minutes" selected>Minutes</option>
15
+ <option value="hours">Hours</option>
16
+ </select>
17
+ </div>
18
+ <div class="form-row">
19
+ <label for="node-input-minValid"><i class="fa fa-arrow-down"></i> Minimum Valid Temp</label>
20
+ <input type="text" id="node-input-minValid" placeholder="-40">
21
+ <input type="hidden" id="node-input-minValidType">
22
+ </div>
23
+ <div class="form-row">
24
+ <label for="node-input-maxValid"><i class="fa fa-arrow-up"></i> Maximum Valid Temp</label>
25
+ <input type="text" id="node-input-maxValid" placeholder="150">
26
+ <input type="hidden" id="node-input-maxValidType">
27
+ </div>
28
+ </script>
29
+
30
+ <script type="text/javascript">
31
+ RED.nodes.registerType("rate-of-change-block", {
32
+ category: "control",
33
+ color: "#301934",
34
+ defaults: {
35
+ name: { value: "" },
36
+ sampleSize: {
37
+ value: 10,
38
+ required: true,
39
+ validate: function(v) { return !isNaN(parseInt(v)) && parseInt(v) >= 2; }
40
+ },
41
+ units: { value: "minutes" },
42
+ minValid: { value: -40, required: true },
43
+ minValidType: { value: "num" },
44
+ maxValid: { value: 150, required: true },
45
+ maxValidType: { value: "num" }
46
+ },
47
+ inputs: 1,
48
+ outputs: 1,
49
+ inputLabels: ["input"],
50
+ outputLabels: ["rate"],
51
+ icon: "font-awesome/fa-bar-chart",
52
+ paletteLabel: "rate of change",
53
+ label: function() {
54
+ return this.name ? `${this.name} (${this.sampleSize} samples)` : `RoC (${this.sampleSize})`;
55
+ },
56
+ oneditprepare: function() {
57
+ const node = this;
58
+
59
+ try {
60
+ // Initialize typed inputs
61
+ $("#node-input-minValid").typedInput({
62
+ default: "num",
63
+ types: ["num", "msg", "flow", "global"],
64
+ typeField: "#node-input-minValidType"
65
+ }).typedInput("type", node.minValidType || "num").typedInput("value", node.minValid || "-40");
66
+
67
+ $("#node-input-maxValid").typedInput({
68
+ default: "num",
69
+ types: ["num", "msg", "flow", "global"],
70
+ typeField: "#node-input-maxValidType"
71
+ }).typedInput("type", node.maxValidType || "num").typedInput("value", node.maxValid || "150");
72
+
73
+ // Set units dropdown
74
+ $("#node-input-units").val(node.units || "minutes");
75
+ } catch (err) {
76
+ console.error("Error in oneditprepare:", err);
77
+ }
78
+ }
79
+ });
80
+ </script>
81
+
82
+ <script type="text/markdown" data-help-name="rate-of-change-block">
83
+ Calculates the rate of temperature change over time for HVAC applications.
84
+
85
+ ### Inputs
86
+ : context (string) : Configures reset (`"reset"`), sample size (`"sampleSize"`), or units (`"units"`).
87
+ : payload (number) : Temperature value for rate calculation.
88
+ : timestamp (optional) : Custom timestamp for the reading.
89
+
90
+ ### Outputs
91
+ : payload (number | null) : Rate of change in temperature per time unit.
92
+ : samples (number) : Current number of samples in buffer.
93
+ : units (string) : Rate units ("°/s", "°/min", "°/hr").
94
+ : currentValue (number) : Most recent temperature value.
95
+ : timeSpan (number) : Time span of sample buffer in seconds.
96
+
97
+ ### Details
98
+ Tracks temperature changes over a rolling window of samples. Calculates rate as (last_value - first_value) / time_difference.
99
+
100
+ Useful for detecting HVAC issues like temperature droop, defrost cycles, or rapid changes.
101
+
102
+ ### Configuration
103
+ - Reset via `msg.context = "reset"` with `msg.payload = true`
104
+ - Change sample size via `msg.context = "sampleSize"` with numeric payload
105
+ - Change units via `msg.context = "units"` with "seconds", "minutes", or "hours"
106
+
107
+ ### Status
108
+ - Shows current rate with units
109
+ - Color indicates state change
110
+ </script>