@bldgblocks/node-red-contrib-control 0.1.34 → 0.1.36

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.
Files changed (110) hide show
  1. package/nodes/accumulate-block.html +18 -8
  2. package/nodes/accumulate-block.js +39 -44
  3. package/nodes/add-block.html +1 -1
  4. package/nodes/add-block.js +18 -11
  5. package/nodes/alarm-collector.html +260 -0
  6. package/nodes/alarm-collector.js +292 -0
  7. package/nodes/alarm-config.html +129 -0
  8. package/nodes/alarm-config.js +126 -0
  9. package/nodes/alarm-service.html +96 -0
  10. package/nodes/alarm-service.js +142 -0
  11. package/nodes/analog-switch-block.js +25 -36
  12. package/nodes/and-block.js +44 -15
  13. package/nodes/average-block.js +46 -41
  14. package/nodes/boolean-switch-block.js +10 -28
  15. package/nodes/boolean-to-number-block.html +18 -5
  16. package/nodes/boolean-to-number-block.js +24 -16
  17. package/nodes/cache-block.js +24 -37
  18. package/nodes/call-status-block.html +91 -32
  19. package/nodes/call-status-block.js +398 -115
  20. package/nodes/changeover-block.html +5 -0
  21. package/nodes/changeover-block.js +167 -162
  22. package/nodes/comment-block.html +1 -1
  23. package/nodes/comment-block.js +14 -9
  24. package/nodes/compare-block.html +14 -4
  25. package/nodes/compare-block.js +23 -18
  26. package/nodes/contextual-label-block.html +5 -0
  27. package/nodes/contextual-label-block.js +6 -16
  28. package/nodes/convert-block.html +25 -39
  29. package/nodes/convert-block.js +31 -16
  30. package/nodes/count-block.html +11 -5
  31. package/nodes/count-block.js +34 -32
  32. package/nodes/delay-block.js +58 -53
  33. package/nodes/divide-block.js +43 -45
  34. package/nodes/edge-block.html +17 -10
  35. package/nodes/edge-block.js +43 -41
  36. package/nodes/enum-switch-block.js +6 -6
  37. package/nodes/frequency-block.html +6 -1
  38. package/nodes/frequency-block.js +64 -74
  39. package/nodes/global-getter.html +51 -15
  40. package/nodes/global-getter.js +43 -13
  41. package/nodes/global-setter.html +1 -1
  42. package/nodes/global-setter.js +40 -12
  43. package/nodes/history-buffer.html +96 -0
  44. package/nodes/history-buffer.js +461 -0
  45. package/nodes/history-collector.html +29 -1
  46. package/nodes/history-collector.js +37 -16
  47. package/nodes/history-config.html +13 -1
  48. package/nodes/history-service.html +84 -0
  49. package/nodes/history-service.js +52 -0
  50. package/nodes/hysteresis-block.html +5 -0
  51. package/nodes/hysteresis-block.js +13 -16
  52. package/nodes/interpolate-block.html +20 -2
  53. package/nodes/interpolate-block.js +39 -50
  54. package/nodes/join.html +78 -0
  55. package/nodes/join.js +78 -0
  56. package/nodes/latch-block.js +12 -14
  57. package/nodes/load-sequence-block.js +102 -110
  58. package/nodes/max-block.js +26 -26
  59. package/nodes/memory-block.js +57 -58
  60. package/nodes/min-block.js +26 -25
  61. package/nodes/minmax-block.js +35 -34
  62. package/nodes/modulo-block.js +45 -43
  63. package/nodes/multiply-block.js +43 -41
  64. package/nodes/negate-block.html +17 -7
  65. package/nodes/negate-block.js +25 -19
  66. package/nodes/network-point-read.html +128 -0
  67. package/nodes/network-point-read.js +230 -0
  68. package/nodes/{network-register.html → network-point-register.html} +94 -7
  69. package/nodes/{network-register.js → network-point-register.js} +18 -4
  70. package/nodes/network-point-write.html +149 -0
  71. package/nodes/network-point-write.js +222 -0
  72. package/nodes/network-service-bridge.html +131 -0
  73. package/nodes/network-service-bridge.js +376 -0
  74. package/nodes/network-service-read.html +81 -0
  75. package/nodes/{network-read.js → network-service-read.js} +4 -3
  76. package/nodes/{network-point-registry.html → network-service-registry.html} +19 -4
  77. package/nodes/{network-point-registry.js → network-service-registry.js} +7 -2
  78. package/nodes/network-service-write.html +89 -0
  79. package/nodes/{network-write.js → network-service-write.js} +3 -3
  80. package/nodes/nullify-block.js +13 -15
  81. package/nodes/on-change-block.html +17 -9
  82. package/nodes/on-change-block.js +49 -46
  83. package/nodes/oneshot-block.html +13 -10
  84. package/nodes/oneshot-block.js +57 -75
  85. package/nodes/or-block.js +44 -15
  86. package/nodes/pid-block.html +54 -4
  87. package/nodes/pid-block.js +459 -248
  88. package/nodes/priority-block.js +24 -35
  89. package/nodes/rate-limit-block.js +70 -72
  90. package/nodes/rate-of-change-block.html +33 -14
  91. package/nodes/rate-of-change-block.js +74 -62
  92. package/nodes/round-block.html +14 -9
  93. package/nodes/round-block.js +32 -25
  94. package/nodes/saw-tooth-wave-block.js +49 -76
  95. package/nodes/scale-range-block.html +12 -6
  96. package/nodes/scale-range-block.js +46 -39
  97. package/nodes/sine-wave-block.js +49 -57
  98. package/nodes/string-builder-block.js +6 -6
  99. package/nodes/subtract-block.js +38 -34
  100. package/nodes/thermistor-block.js +44 -44
  101. package/nodes/tick-tock-block.js +32 -32
  102. package/nodes/time-sequence-block.js +30 -42
  103. package/nodes/triangle-wave-block.js +49 -69
  104. package/nodes/tstat-block.js +34 -44
  105. package/nodes/units-block.html +90 -69
  106. package/nodes/units-block.js +22 -30
  107. package/nodes/utils.js +206 -3
  108. package/package.json +14 -6
  109. package/nodes/network-read.html +0 -56
  110. package/nodes/network-write.html +0 -65
@@ -1,3 +1,45 @@
1
+ // ============================================================================
2
+ // PID Block - Proportional-Integral-Derivative Controller
3
+ // ============================================================================
4
+ // A full-featured PID controller for Node-RED with:
5
+ // - Configurable Kp, Ki, Kd gains (can be dynamic via msg or config)
6
+ // - Direct and Reverse action modes (heating vs cooling applications)
7
+ // - Deadband support (ReturnToZero or HoldLastResult behavior)
8
+ // - Output limiting and rate-of-change limiting
9
+ // - Anti-windup integral clamping
10
+ // - Automatic tuning (Ziegler-Nichols method)
11
+ // - Dynamic parameter updates via msg.context
12
+ // ============================================================================
13
+ //
14
+ // KEY CONCEPTS:
15
+ // =============
16
+ // Error = setpoint - input (for reverse action: heating)
17
+ // OR input - setpoint (for direct action: cooling)
18
+ // Output represents demand: positive = need action, negative = excess
19
+ //
20
+ // P term (Proportional): responds immediately to error
21
+ // - Too high: system oscillates around setpoint
22
+ // - Too low: slow response, doesn't reach setpoint
23
+ //
24
+ // I term (Integral): removes steady-state error by accumulating error over time
25
+ // - Eliminates offset (P alone can't reach exact setpoint)
26
+ // - Too high: causes slower response or oscillation
27
+ // - Anti-windup prevents excessive accumulation when limits are hit
28
+ //
29
+ // D term (Derivative): dampens response based on rate of change
30
+ // - Helps prevent overshoot
31
+ // - Low-pass filtered to prevent noise amplification
32
+ // - Can cause problems with noisy sensors
33
+ //
34
+ // Tuning Tips:
35
+ // - Start with conservative Kp (0.1-1.0), set Ki=0, Kd=0
36
+ // - Increase Kp until system oscillates slightly, back off 30%
37
+ // - Add Ki to remove offset (start at Ki = Kp/100)
38
+ // - Add Kd to dampen oscillation (start at Kd = Kp * interval)
39
+ // - Use auto-tune feature for initial estimates
40
+ //
41
+ // ============================================================================
42
+
1
43
  module.exports = function(RED) {
2
44
  const utils = require('./utils')(RED);
3
45
 
@@ -5,31 +47,37 @@ module.exports = function(RED) {
5
47
  RED.nodes.createNode(this, config);
6
48
 
7
49
  const node = this;
8
- node.isBusy = false;
9
-
10
- // Initialize runtime state
11
- node.runtime = {
12
- name: config.name,
13
- dbBehavior: config.dbBehavior,
14
- errorSum: 0,
15
- lastError: 0,
16
- lastDError: 0,
17
- result: 0,
18
- lastTime: Date.now(),
19
- tuneMode: false,
20
- tuneData: { oscillations: [], lastPeak: null, lastTrough: null, Ku: 0, Tu: 0 },
21
- kp: parseFloat(config.kp),
22
- ki: parseFloat(config.ki),
23
- kd: parseFloat(config.kd),
24
- setpoint: parseFloat(config.setpoint),
25
- deadband: parseFloat(config.deadband),
26
- outMin: config.outMin ? parseFloat(config.outMin) : null,
27
- outMax: config.outMax ? parseFloat(config.outMax) : null,
28
- maxChange: parseFloat(config.maxChange),
29
- run: !!config.run,
30
- };
31
-
32
- // Initialize internal variables
50
+ node.isBusy = false; // Lock to prevent concurrent message processing
51
+
52
+ // ====================================================================
53
+ // Initialize state - values that change during operation
54
+ // ====================================================================
55
+ node.name = config.name;
56
+ node.inputProperty = config.inputProperty || "payload"; // Where to read input value from msg
57
+ node.dbBehavior = config.dbBehavior; // "ReturnToZero" or "HoldLastResult" - what to do in deadband
58
+ node.errorSum = 0; // Accumulated error for integral term (I in PID)
59
+ node.lastError = 0; // Previous error value for derivative calculation
60
+ node.lastDError = 0; // Filtered derivative of error (prevents noise spikes)
61
+ node.result = 0; // Current output value
62
+ node.lastTime = Date.now(); // Timestamp of last calculation for interval calculation
63
+ node.setpoint = parseFloat(config.setpoint); // Current setpoint value (may be rate-limited)
64
+ node.setpointRaw = parseFloat(config.setpoint); // Raw setpoint value (before rate limiting)
65
+ node.tuneMode = false; // Auto-tuning mode active?
66
+ node.tuneData = { relayOutput: 1, peaks: [], lastPeak: null, lastTrough: null, oscillationCount: 0, startTime: null, Ku: 0, Tu: 0 };
67
+ node.kp = parseFloat(config.kp); // Proportional gain
68
+ node.ki = parseFloat(config.ki); // Integral gain
69
+ node.kd = parseFloat(config.kd); // Derivative gain
70
+ node.setpointRateLimit = config.setpointRateLimit ? parseFloat(config.setpointRateLimit) : 0; // Max setpoint change per second
71
+ node.deadband = parseFloat(config.deadband); // Zone around setpoint where no output
72
+ node.outMin = config.outMin ? parseFloat(config.outMin) : null; // Minimum output limit
73
+ node.outMax = config.outMax ? parseFloat(config.outMax) : null; // Maximum output limit
74
+ node.maxChange = parseFloat(config.maxChange); // Maximum change per second (rate limiting)
75
+ node.run = !!config.run; // Controller enabled/disabled
76
+ node.directAction = !!config.directAction; // true=cooling (temp↑→out↑), false=heating (temp↑→out↓)
77
+
78
+ // ====================================================================
79
+ // Initialize internal variables - for tracking changes and constraints
80
+ // =====================================================================
33
81
  let storekp = parseFloat(config.kp) || 0;
34
82
  let storeki = parseFloat(config.ki) || 0;
35
83
  let storekd = parseFloat(config.kd) || 0;
@@ -40,113 +88,129 @@ module.exports = function(RED) {
40
88
  let storemaxChange = parseFloat(config.maxChange) || 0;
41
89
  let storerun = !!config.run; // convert to boolean
42
90
 
91
+ // Integral constraint bounds - prevents integral wind-up
92
+ // minInt/maxInt = output limits * (Kp * Ki) to keep integral gain in bounds
43
93
  let kpkiConst = storekp * storeki;
44
94
  let minInt = kpkiConst === 0 ? 0 : (storeOutMin || -Infinity) * kpkiConst;
45
95
  let maxInt = kpkiConst === 0 ? 0 : (storeOutMax || Infinity) * kpkiConst;
46
- let lastOutput = null;
96
+ let lastOutput = null; // Track last output to avoid duplicate sends
47
97
 
98
+ // =====================================================================
99
+ // Main message handler - processes incoming input and context updates
100
+ // ====================================================================
48
101
  node.on("input", async function(msg, send, done) {
49
102
  send = send || function() { node.send.apply(node, arguments); };
50
103
 
51
104
  // Guard against invalid message
52
105
  if (!msg) {
53
- node.status({ fill: "red", shape: "ring", text: "invalid message" });
106
+ utils.setStatusError(node, "invalid message");
54
107
  if (done) done();
55
108
  return;
56
109
  }
57
110
 
58
- // Evaluate dynamic properties
111
+ // ================================================================
112
+ // Evaluate dynamic properties (Kp, Ki, Kd, setpoint, etc.)
113
+ // These can be static config or dynamic (from msg or context)
114
+ // ================================================================
59
115
  try {
60
-
61
- // Check busy lock
116
+ // Check busy lock - prevent concurrent processing since we're async
62
117
  if (node.isBusy) {
63
- // Update status to let user know they are pushing too fast
64
- node.status({ fill: "yellow", shape: "ring", text: "busy - dropped msg" });
118
+ // Drop message if already processing (too fast)
119
+ utils.setStatusBusy(node, "busy - dropped msg");
65
120
  if (done) done();
66
121
  return;
67
122
  }
68
123
 
69
- // Lock node during evaluation
124
+ // Lock node during evaluation phase
70
125
  node.isBusy = true;
71
126
 
72
- // Begin evaluations
73
- const evaluations = [];
127
+ // Evaluate all configurable properties in parallel
128
+ // Each can be static config (num) or dynamic (str expression, msg property, etc.)
129
+ const evaluations = [];
74
130
 
131
+ // Proportional gain - can be dynamic
75
132
  evaluations.push(
76
133
  utils.requiresEvaluation(config.kpType)
77
134
  ? utils.evaluateNodeProperty(config.kp, config.kpType, node, msg)
78
135
  .then(val => parseFloat(val))
79
- : Promise.resolve(node.runtime.kp),
136
+ : Promise.resolve(node.kp),
80
137
  );
81
138
 
82
139
  evaluations.push(
83
140
  utils.requiresEvaluation(config.kiType)
84
141
  ? utils.evaluateNodeProperty(config.ki, config.kiType, node, msg)
85
142
  .then(val => parseFloat(val))
86
- : Promise.resolve(node.runtime.ki),
143
+ : Promise.resolve(node.ki),
87
144
  );
88
145
 
89
146
  evaluations.push(
90
147
  utils.requiresEvaluation(config.kdType)
91
148
  ? utils.evaluateNodeProperty(config.kd, config.kdType, node, msg)
92
149
  .then(val => parseFloat(val))
93
- : Promise.resolve(node.runtime.kd),
150
+ : Promise.resolve(node.kd),
94
151
  );
95
152
 
96
153
  evaluations.push(
97
154
  utils.requiresEvaluation(config.setpointType)
98
155
  ? utils.evaluateNodeProperty(config.setpoint, config.setpointType, node, msg)
99
156
  .then(val => parseFloat(val))
100
- : Promise.resolve(node.runtime.setpoint),
157
+ : Promise.resolve(node.setpoint),
101
158
  );
102
159
 
103
160
  evaluations.push(
104
161
  utils.requiresEvaluation(config.deadbandType)
105
162
  ? utils.evaluateNodeProperty(config.deadband, config.deadbandType, node, msg)
106
163
  .then(val => parseFloat(val))
107
- : Promise.resolve(node.runtime.deadband),
164
+ : Promise.resolve(node.deadband),
108
165
  );
109
166
 
110
167
  evaluations.push(
111
168
  utils.requiresEvaluation(config.outMinType)
112
169
  ? utils.evaluateNodeProperty(config.outMin, config.outMinType, node, msg)
113
170
  .then(val => parseFloat(val))
114
- : Promise.resolve(node.runtime.outMin),
171
+ : Promise.resolve(node.outMin),
115
172
  );
116
173
 
117
174
  evaluations.push(
118
175
  utils.requiresEvaluation(config.outMaxType)
119
176
  ? utils.evaluateNodeProperty(config.outMax, config.outMaxType, node, msg)
120
177
  .then(val => parseFloat(val))
121
- : Promise.resolve(node.runtime.outMax),
178
+ : Promise.resolve(node.outMax),
122
179
  );
123
180
 
124
181
  evaluations.push(
125
182
  utils.requiresEvaluation(config.maxChangeType)
126
183
  ? utils.evaluateNodeProperty(config.maxChange, config.maxChangeType, node, msg)
127
184
  .then(val => parseFloat(val))
128
- : Promise.resolve(node.runtime.maxChange),
185
+ : Promise.resolve(node.maxChange),
129
186
  );
130
187
 
131
188
  evaluations.push(
132
189
  utils.requiresEvaluation(config.runType)
133
190
  ? utils.evaluateNodeProperty(config.run, config.runType, node, msg)
134
191
  .then(val => val === true)
135
- : Promise.resolve(node.runtime.run),
192
+ : Promise.resolve(node.run),
136
193
  );
137
194
 
138
- const results = await Promise.all(evaluations);
195
+ const results = await Promise.all(evaluations);
139
196
 
140
197
  // Update runtime with evaluated values
141
- if (!isNaN(results[0])) node.runtime.kp = results[0];
142
- if (!isNaN(results[1])) node.runtime.ki = results[1];
143
- if (!isNaN(results[2])) node.runtime.kd = results[2];
144
- if (!isNaN(results[3])) node.runtime.setpoint = results[3];
145
- if (!isNaN(results[4])) node.runtime.deadband = results[4];
146
- if (!isNaN(results[5])) node.runtime.outMin = results[5];
147
- if (!isNaN(results[6])) node.runtime.outMax = results[6];
148
- if (!isNaN(results[7])) node.runtime.maxChange = results[7];
149
- if (results[8] != null) node.runtime.run = results[8];
198
+ if (!isNaN(results[0])) node.kp = results[0];
199
+ if (!isNaN(results[1])) node.ki = results[1];
200
+ if (!isNaN(results[2])) node.kd = results[2];
201
+
202
+ if (!isNaN(results[4])) node.deadband = results[4];
203
+ if (!isNaN(results[5])) node.outMin = results[5];
204
+ if (!isNaN(results[6])) node.outMax = results[6];
205
+ if (!isNaN(results[7])) node.maxChange = results[7];
206
+ if (results[8] != null) node.run = results[8];
207
+
208
+ if (!isNaN(results[3])) {
209
+ node.setpoint = results[3];
210
+ // Sync raw value immediately so rate limiter has the correct target
211
+ node.setpointRaw = results[3];
212
+ }
213
+
150
214
  } catch (err) {
151
215
  node.error(`Error evaluating properties: ${err.message}`);
152
216
  if (done) done();
@@ -156,108 +220,133 @@ module.exports = function(RED) {
156
220
  node.isBusy = false;
157
221
  }
158
222
 
223
+ // ================================================================
224
+ // Configuration validation - ensure all values are valid numbers
225
+ // ================================================================
226
+ // Validate and sanitize all configuration values
227
+ if (isNaN(node.kp) || !isFinite(node.kp)) node.kp = 0;
228
+ if (isNaN(node.ki) || !isFinite(node.ki)) node.ki = 0;
229
+ if (isNaN(node.kd) || !isFinite(node.kd)) node.kd = 0;
230
+ if (isNaN(node.setpoint) || !isFinite(node.setpoint)) node.setpoint = 0;
231
+ if (isNaN(node.setpointRaw) || !isFinite(node.setpointRaw)) node.setpointRaw = 0;
232
+ if (isNaN(node.deadband) || !isFinite(node.deadband)) node.deadband = 0;
233
+ if (isNaN(node.maxChange) || !isFinite(node.maxChange)) node.maxChange = 0;
234
+ if (isNaN(node.setpointRateLimit) || !isFinite(node.setpointRateLimit)) node.setpointRateLimit = 0;
235
+ if (node.outMin !== null && (isNaN(node.outMin) || !isFinite(node.outMin))) node.outMin = null;
236
+ if (node.outMax !== null && (isNaN(node.outMax) || !isFinite(node.outMax))) node.outMax = null;
237
+
159
238
  // Validate config
160
- if (isNaN(node.runtime.kp) || isNaN(node.runtime.ki) || isNaN(node.runtime.kd) ||
161
- isNaN(node.runtime.setpoint) || isNaN(node.runtime.deadband) || isNaN(node.runtime.maxChange) ||
162
- !isFinite(node.runtime.kp) || !isFinite(node.runtime.ki) || !isFinite(node.runtime.kd) ||
163
- !isFinite(node.runtime.setpoint) || !isFinite(node.runtime.deadband) || !isFinite(node.runtime.maxChange)) {
164
- node.status({ fill: "red", shape: "ring", text: "invalid config" });
165
- node.runtime.kp = node.runtime.ki = node.runtime.kd = node.runtime.setpoint = node.runtime.deadband = node.runtime.maxChange = 0;
239
+ if (node.deadband < 0 || node.maxChange < 0) {
240
+ utils.setStatusError(node, "invalid deadband or maxChange");
241
+ node.deadband = node.maxChange = 0;
166
242
  }
167
- if (node.runtime.deadband < 0 || node.runtime.maxChange < 0) {
168
- node.status({ fill: "red", shape: "ring", text: "invalid deadband or maxChange" });
169
- node.runtime.deadband = node.runtime.maxChange = 0;
243
+ if (node.outMin != null && node.outMax != null && node.outMax <= node.outMin) {
244
+ utils.setStatusError(node, "invalid output range");
245
+ node.outMin = node.outMax = null;
170
246
  }
171
- if (node.runtime.outMin != null && node.runtime.outMax != null && node.runtime.outMax <= node.runtime.outMin) {
172
- node.status({ fill: "red", shape: "ring", text: "invalid output range" });
173
- node.runtime.outMin = node.runtime.outMax = null;
174
- }
175
- if (!["ReturnToZero", "HoldLastResult"].includes(node.runtime.dbBehavior)) {
176
- node.status({ fill: "red", shape: "ring", text: "invalid dbBehavior" });
177
- node.runtime.dbBehavior = "ReturnToZero";
247
+ if (!["ReturnToZero", "HoldLastResult"].includes(node.dbBehavior)) {
248
+ utils.setStatusError(node, "invalid dbBehavior");
249
+ node.dbBehavior = "ReturnToZero";
178
250
  }
179
251
 
180
- // Handle context updates
252
+ // ================================================================
253
+ // Handle context updates - msg.context allows dynamic parameter changes
254
+ // Supports: setpoint, kp, ki, kd, deadband, outMin, outMax, maxChange,
255
+ // run, directAction, dbBehavior, reset, tune
256
+ // ================================================================
181
257
  if (msg.hasOwnProperty("context")) {
182
258
  if (!msg.hasOwnProperty("payload")) {
183
- node.status({ fill: "red", shape: "ring", text: `missing payload for ${msg.context}` });
259
+ utils.setStatusError(node, `missing payload for ${msg.context}`);
184
260
  if (done) done();
185
261
  return;
186
262
  }
187
263
  if (typeof msg.context !== "string") {
188
- node.status({ fill: "red", shape: "ring", text: "invalid context" });
264
+ utils.setStatusError(node, "invalid context");
189
265
  if (done) done();
190
266
  return;
191
267
  }
192
- if (["setpoint", "kp", "ki", "kd", "deadband", "outMin", "outMax", "maxChange"].includes(msg.context)) {
268
+ if (["setpoint", "kp", "ki", "kd", "deadband", "outMin", "outMax", "maxChange", "setpointRateLimit"].includes(msg.context)) {
193
269
  let value = parseFloat(msg.payload);
194
270
  if (isNaN(value) || !isFinite(value)) {
195
- node.status({ fill: "red", shape: "ring", text: `invalid ${msg.context}` });
271
+ utils.setStatusError(node, `invalid ${msg.context}`);
196
272
  if (done) done();
197
273
  return;
198
274
  }
199
- if ((msg.context === "deadband" || msg.context === "maxChange") && value < 0) {
200
- node.status({ fill: "red", shape: "ring", text: `invalid ${msg.context}` });
275
+ if ((msg.context === "deadband" || msg.context === "maxChange" || msg.context === "setpointRateLimit") && value < 0) {
276
+ utils.setStatusError(node, `invalid ${msg.context}`);
201
277
  if (done) done();
202
278
  return;
203
279
  }
204
- node.runtime[msg.context] = value;
280
+ if (msg.context === "setpoint") {
281
+ // Store raw setpoint value for rate limiting
282
+ node.setpointRaw = value;
283
+ } else {
284
+ node[msg.context] = value;
285
+ }
205
286
  if (msg.context === "outMin" || msg.context === "outMax") {
206
- if (node.runtime.outMin != null && node.runtime.outMax != null && node.runtime.outMax <= node.runtime.outMin) {
207
- node.status({ fill: "red", shape: "ring", text: "invalid output range" });
287
+ if (node.outMin != null && node.outMax != null && node.outMax <= node.outMin) {
288
+ utils.setStatusError(node, "invalid output range");
208
289
  if (done) done();
209
290
  return;
210
291
  }
211
292
  }
212
- node.status({ fill: "green", shape: "dot", text: `${msg.context}: ${value.toFixed(2)}` });
293
+ utils.setStatusOK(node, `${msg.context}: ${value.toFixed(2)}`);
294
+ if (done) done();
295
+ return;
213
296
  } else if (["run", "directAction"].includes(msg.context)) {
214
297
  if (typeof msg.payload !== "boolean") {
215
- node.status({ fill: "red", shape: "ring", text: `invalid ${msg.context}` });
298
+ utils.setStatusError(node, `invalid ${msg.context}`);
216
299
  if (done) done();
217
300
  return;
218
301
  }
219
- node.runtime[msg.context] = msg.payload;
220
- node.status({ fill: "green", shape: "dot", text: `${msg.context}: ${msg.payload}` });
302
+ node[msg.context] = msg.payload;
303
+ utils.setStatusOK(node, `${msg.context}: ${msg.payload}`);
304
+ if (done) done();
305
+ return;
221
306
  } else if (msg.context === "dbBehavior") {
222
307
  if (!["ReturnToZero", "HoldLastResult"].includes(msg.payload)) {
223
- node.status({ fill: "red", shape: "ring", text: "invalid dbBehavior" });
308
+ utils.setStatusError(node, "invalid dbBehavior");
224
309
  if (done) done();
225
310
  return;
226
311
  }
227
- node.runtime.dbBehavior = msg.payload;
228
- node.status({ fill: "green", shape: "dot", text: `dbBehavior: ${msg.payload}` });
312
+ node.dbBehavior = msg.payload;
313
+ utils.setStatusOK(node, `dbBehavior: ${msg.payload}`);
314
+ if (done) done();
315
+ return;
229
316
  } else if (msg.context === "reset") {
230
317
  if (typeof msg.payload !== "boolean" || !msg.payload) {
231
- node.status({ fill: "red", shape: "ring", text: "invalid reset" });
318
+ utils.setStatusError(node, "invalid reset");
232
319
  if (done) done();
233
320
  return;
234
321
  }
235
- node.runtime.errorSum = 0;
236
- node.runtime.lastError = 0;
237
- node.runtime.lastDError = 0;
238
- node.runtime.result = 0;
239
- node.runtime.tuneMode = false;
240
- node.runtime.tuneData = { oscillations: [], lastPeak: null, lastTrough: null, Ku: 0, Tu: 0 };
241
- node.status({ fill: "green", shape: "dot", text: "reset" });
322
+ node.errorSum = 0;
323
+ node.lastError = 0;
324
+ node.lastDError = 0;
325
+ node.result = 0;
326
+ node.tuneMode = false;
327
+ node.tuneData = { relayOutput: 1, peaks: [], lastPeak: null, lastTrough: null, oscillationCount: 0, startTime: null, Ku: 0, Tu: 0 };
328
+ utils.setStatusOK(node, "reset");
329
+ if (done) done();
330
+ return;
242
331
  if (done) done();
243
332
  return;
244
333
  } else if (msg.context === "tune") {
245
- let tuneKp = parseFloat(msg.payload);
246
- if (isNaN(tuneKp) || !isFinite(tuneKp) || tuneKp <= 0) {
247
- node.status({ fill: "red", shape: "ring", text: "invalid tune kp" });
334
+ if (typeof msg.payload !== "boolean" || !msg.payload) {
335
+ utils.setStatusError(node, "invalid tune command");
248
336
  if (done) done();
249
337
  return;
250
338
  }
251
- node.runtime.tuneMode = true;
252
- node.runtime.kp = tuneKp;
253
- node.runtime.ki = 0;
254
- node.runtime.kd = 0;
255
- node.runtime.tuneData = { oscillations: [], lastPeak: null, lastTrough: null, Ku: 0, Tu: 0 };
256
- node.status({ fill: "green", shape: "dot", text: `tune: started, kp=${tuneKp.toFixed(2)}` });
339
+ node.tuneMode = true;
340
+ node.tuneData = { relayOutput: 1, peaks: [], lastPeak: null, lastTrough: null, oscillationCount: 0, startTime: null, Ku: 0, Tu: 0 };
341
+ node.errorSum = 0;
342
+ node.lastError = 0;
343
+ utils.setStatusBusy(node, "tune: starting relay auto-tuning...");
344
+ if (done) done();
345
+ return;
257
346
  if (done) done();
258
347
  return;
259
348
  } else {
260
- node.status({ fill: "yellow", shape: "ring", text: "unknown context" });
349
+ utils.setStatusWarn(node, "unknown context");
261
350
  if (done) done("Unknown context");
262
351
  return;
263
352
  }
@@ -265,208 +354,330 @@ module.exports = function(RED) {
265
354
  return;
266
355
  }
267
356
 
268
- if (!msg.hasOwnProperty("payload")) {
269
- node.status({ fill: "red", shape: "ring", text: "missing payload" });
270
- if (done) done();
271
- return;
357
+ // ================================================================
358
+ // Read input value from configurable message property
359
+ // Example: msg.data.temperature or msg.payload
360
+ // If input is missing or invalid, output 0 (safe failsafe)
361
+ // ================================================================
362
+ let inputValue;
363
+ try {
364
+ inputValue = RED.util.getMessageProperty(msg, node.inputProperty);
365
+ } catch (err) {
366
+ inputValue = undefined;
272
367
  }
273
-
274
- const input = parseFloat(msg.payload);
275
- if (isNaN(input) || !isFinite(input)) {
276
- node.status({ fill: "red", shape: "ring", text: "invalid payload" });
277
- if (done) done();
278
- return;
368
+ let input;
369
+
370
+ if (inputValue === undefined || inputValue === null) {
371
+ utils.setStatusError(node, "missing or invalid input property");
372
+ input = 0; // Failsafe: output 0 instead of NaN
373
+ } else {
374
+ input = parseFloat(inputValue);
375
+ if (isNaN(input) || !isFinite(input)) {
376
+ utils.setStatusError(node, "invalid input property");
377
+ input = 0; // Failsafe: output 0 instead of NaN
378
+ }
279
379
  }
280
380
 
281
- // PID Calculation
381
+ // ================================================================
382
+ // Calculate time elapsed since last execution (interval in seconds)
383
+ // This is critical: PID gains are time-dependent
384
+ // ================================================================
282
385
  let currentTime = Date.now();
283
- let interval = (currentTime - node.runtime.lastTime) / 1000; // Seconds
284
- node.runtime.lastTime = currentTime;
386
+ let interval = (currentTime - node.lastTime) / 1000; // Convert to seconds
387
+ node.lastTime = currentTime;
285
388
 
286
389
  let outputMsg = { payload: 0 };
287
390
  outputMsg.diagnostics = {
288
- setpoint: node.runtime.setpoint,
391
+ setpoint: node.setpoint,
289
392
  interval,
290
393
  lastOutput,
291
- run: node.runtime.run,
292
- directAction: node.runtime.directAction,
293
- kp: node.runtime.kp,
294
- ki: node.runtime.ki,
295
- kd: node.runtime.kd
394
+ run: node.run,
395
+ directAction: node.directAction,
396
+ kp: node.kp,
397
+ ki: node.ki,
398
+ kd: node.kd
296
399
  };
297
400
 
298
- if (!node.runtime.run || interval <= 0 || node.runtime.kp === 0) {
401
+ // ================================================================
402
+ // Early exit conditions - skip PID calculation if:
403
+ // - Controller not running (run=false)
404
+ // - interval <= 0: First execution, no time elapsed
405
+ // - interval > 60: Time jump detected (clock adjustment, suspend/resume)
406
+ // - Kp = 0: No proportional gain, no control possible
407
+ // ================================================================
408
+ if (!node.run || interval <= 0 || interval > 60 || node.kp === 0) {
299
409
  if (lastOutput !== 0) {
300
410
  lastOutput = 0;
301
- node.status({
302
- fill: "blue",
303
- shape: "dot",
304
- text: `in: ${input.toFixed(2)}, out: 0.00, setpoint: ${node.runtime.setpoint.toFixed(2)}`
305
- });
411
+ utils.setStatusChanged(node, `in: ${input.toFixed(2)}, out: 0.00, setpoint: ${node.setpoint.toFixed(2)}`);
306
412
  } else {
307
- node.status({
308
- fill: "blue",
309
- shape: "ring",
310
- text: `in: ${input.toFixed(2)}, out: 0.00, setpoint: ${node.runtime.setpoint.toFixed(2)}`
311
- });
413
+ utils.setStatusUnchanged(node, `in: ${input.toFixed(2)}, out: 0.00, setpoint: ${node.setpoint.toFixed(2)}`);
312
414
  }
313
415
  send(outputMsg);
314
416
  if (done) done();
315
417
  return;
316
418
  }
317
419
 
318
- // Deadband check
319
- if (node.runtime.deadband !== 0 && input <= node.runtime.setpoint + node.runtime.deadband && input >= node.runtime.setpoint - node.runtime.deadband) {
320
- outputMsg.payload = node.runtime.dbBehavior === "ReturnToZero" ? 0 : node.runtime.result;
420
+ // ================================================================
421
+ // Deadband check - zone around setpoint where no output is generated
422
+ // This prevents oscillation when input is very close to target
423
+ // ================================================================
424
+ if (node.deadband !== 0 && input <= node.setpoint + node.deadband && input >= node.setpoint - node.deadband) {
425
+ // Reset derivative term to prevent kick when exiting deadband
426
+ // Without this, large derivative spike occurs on deadband exit
427
+ node.lastDError = 0;
428
+ outputMsg.payload = node.dbBehavior === "ReturnToZero" ? 0 : node.result;
321
429
  const outputChanged = !lastOutput || lastOutput !== outputMsg.payload;
322
430
  if (outputChanged) {
323
431
  lastOutput = outputMsg.payload;
324
- node.status({
325
- fill: "blue",
326
- shape: "dot",
327
- text: `in: ${input.toFixed(2)}, out: ${outputMsg.payload.toFixed(2)}, setpoint: ${node.runtime.setpoint.toFixed(2)}`
328
- });
432
+ utils.setStatusChanged(node, `in: ${input.toFixed(2)}, out: ${outputMsg.payload.toFixed(2)}, setpoint: ${node.setpoint.toFixed(2)}`);
329
433
  send(outputMsg);
330
434
  } else {
331
- node.status({
332
- fill: "blue",
333
- shape: "ring",
334
- text: `in: ${input.toFixed(2)}, out: ${outputMsg.payload.toFixed(2)}, setpoint: ${node.runtime.setpoint.toFixed(2)}`
335
- });
435
+ utils.setStatusUnchanged(node, `in: ${input.toFixed(2)}, out: ${outputMsg.payload.toFixed(2)}, setpoint: ${node.setpoint.toFixed(2)}`);
336
436
  }
337
437
  if (done) done();
338
438
  return;
339
439
  }
340
440
 
341
- // Update internal constraints
342
- if (node.runtime.kp !== storekp || node.runtime.ki !== storeki || node.runtime.outMin !== storeOutMin || node.runtime.outMax !== storeOutMax) {
343
- if (node.runtime.kp !== storekp && node.runtime.kp !== 0 && storekp !== 0) {
344
- node.runtime.errorSum = node.runtime.errorSum * storekp / node.runtime.kp;
441
+ // ================================================================
442
+ // Update integral constraint limits when gains or output limits change
443
+ // This rescales the accumulated error (errorSum) proportionally
444
+ // ================================================================
445
+ if (node.kp !== storekp || node.ki !== storeki || node.outMin !== storeOutMin || node.outMax !== storeOutMax) {
446
+ if (node.kp !== storekp && node.kp !== 0 && storekp !== 0) {
447
+ node.errorSum = node.errorSum * storekp / node.kp;
345
448
  }
346
- if (node.runtime.ki !== storeki && node.runtime.ki !== 0 && storeki !== 0) {
347
- node.runtime.errorSum = node.runtime.errorSum * storeki / node.runtime.ki;
449
+ if (node.ki !== storeki && node.ki !== 0 && storeki !== 0) {
450
+ node.errorSum = node.errorSum * storeki / node.ki;
348
451
  }
349
- kpkiConst = node.runtime.kp * node.runtime.ki;
350
- minInt = kpkiConst === 0 ? 0 : (node.runtime.outMin || -Infinity) * kpkiConst;
351
- maxInt = kpkiConst === 0 ? 0 : (node.runtime.outMax || Infinity) * kpkiConst;
352
- storekp = node.runtime.kp;
353
- storeki = node.runtime.ki;
354
- storeOutMin = node.runtime.outMin;
355
- storeOutMax = node.runtime.outMax;
452
+ kpkiConst = node.kp * node.ki;
453
+ minInt = kpkiConst === 0 ? 0 : (node.outMin || -Infinity) * kpkiConst;
454
+ maxInt = kpkiConst === 0 ? 0 : (node.outMax || Infinity) * kpkiConst;
455
+ storekp = node.kp;
456
+ storeki = node.ki;
457
+ storeOutMin = node.outMin;
458
+ storeOutMax = node.outMax;
356
459
  }
357
460
 
358
- // Calculate error
359
- let error = node.runtime.setpoint - input;
461
+ // ================================================================
462
+ // Apply setpoint rate limiting to prevent integrator wind-up and thermal shock
463
+ // Smoothly ramps setpoint changes at configured rate (units per second)
464
+ // ================================================================
465
+ if (node.setpointRateLimit > 0) {
466
+ let setpointChange = node.setpointRaw - node.setpoint;
467
+ let maxAllowedChange = node.setpointRateLimit * interval;
468
+
469
+ if (Math.abs(setpointChange) > maxAllowedChange) {
470
+ // Ramp setpoint towards target at limited rate
471
+ node.setpoint += Math.sign(setpointChange) * maxAllowedChange;
472
+ } else {
473
+ // Close enough to target, snap to it
474
+ node.setpoint = node.setpointRaw;
475
+ }
476
+ } else {
477
+ // No rate limiting, use raw setpoint directly
478
+ node.setpoint = node.setpointRaw;
479
+ }
360
480
 
361
- // Tuning assistant (Ziegler-Nichols)
362
- if (node.runtime.tuneMode) {
363
- if (node.runtime.lastError > 0 && error <= 0) { // Peak detected
364
- if (node.runtime.tuneData.lastPeak !== null) {
365
- node.runtime.tuneData.oscillations.push({ time: currentTime, amplitude: node.runtime.tuneData.lastPeak });
481
+ // ================================================================
482
+ // Calculate error - the basis of PID control
483
+ // Reverse action (heating): error = setpoint - input
484
+ // - Temp below setpoint → positive error → positive output (heat)
485
+ // - Temp above setpoint negative error → negative output (cool)
486
+ // Direct action (cooling): error = input - setpoint
487
+ // - Temp above setpoint → positive error → positive output (cool)
488
+ // - Temp below setpoint → negative error → negative output (reduce cooling)
489
+ // In both cases, output magnitude represents demand magnitude
490
+ // ================================================================
491
+ let error = node.directAction ? (input - node.setpoint) : (node.setpoint - input);
492
+
493
+ // ================================================================
494
+ // Relay Auto-Tuning (Improved Ziegler-Nichols)
495
+ // Uses bang-bang relay control to find the critical oscillation point
496
+ // More robust than manual Kp adjustment
497
+ // ================================================================
498
+ if (node.tuneMode) {
499
+ // Initialize relay tuning on first call
500
+ if (node.tuneData.startTime === null) {
501
+ node.tuneData.startTime = currentTime;
502
+ node.tuneData.relayOutput = 1; // Start with output high
503
+ node.errorSum = 0; // Reset integral during tuning
504
+ node.lastError = error;
505
+ }
506
+
507
+ // Apply relay control: output swings between min and max based on error sign
508
+ if (error > node.deadband) {
509
+ node.tuneData.relayOutput = -1; // Error positive: apply cooling
510
+ } else if (error < -node.deadband) {
511
+ node.tuneData.relayOutput = 1; // Error negative: apply heating
512
+ }
513
+
514
+ // Detect peaks and troughs in the error signal
515
+ if (node.lastError > 0 && error <= 0) { // Peak
516
+ if (node.tuneData.lastPeak !== null) {
517
+ node.tuneData.peaks.push({ type: 'peak', value: node.tuneData.lastPeak, time: currentTime });
518
+ }
519
+ node.tuneData.lastPeak = node.lastError;
520
+ node.tuneData.oscillationCount++;
521
+ } else if (node.lastError < 0 && error >= 0) { // Trough
522
+ if (node.tuneData.lastTrough !== null) {
523
+ node.tuneData.peaks.push({ type: 'trough', value: node.tuneData.lastTrough, time: currentTime });
366
524
  }
367
- node.runtime.tuneData.lastPeak = node.runtime.lastError;
368
- } else if (node.runtime.lastError < 0 && error >= 0) { // Trough detected
369
- node.runtime.tuneData.lastTrough = node.runtime.lastError;
525
+ node.tuneData.lastTrough = node.lastError;
526
+ node.tuneData.oscillationCount++;
370
527
  }
371
- if (node.runtime.tuneData.oscillations.length >= 3) { // Enough data to tune
528
+
529
+ // Use relay output as PID result during tuning
530
+ let relayAmplitude = Math.abs((node.outMax || 100) - (node.outMin || 0)) / 2;
531
+ node.result = node.tuneData.relayOutput > 0 ? relayAmplitude : -relayAmplitude;
532
+
533
+ // Check if we have enough oscillations to calculate Tu and Ku
534
+ if (node.tuneData.peaks.length >= 4) {
535
+ // Calculate ultimate period (Tu) from peak-to-peak distances
372
536
  let periodSum = 0;
373
- for (let i = 1; i < node.runtime.tuneData.oscillations.length; i++) {
374
- periodSum += (node.runtime.tuneData.oscillations[i].time - node.runtime.tuneData.oscillations[i-1].time) / 1000;
537
+ for (let i = 2; i < node.tuneData.peaks.length; i++) {
538
+ periodSum += (node.tuneData.peaks[i].time - node.tuneData.peaks[i-2].time) / 1000;
375
539
  }
376
- node.runtime.tuneData.Tu = periodSum / (node.runtime.tuneData.oscillations.length - 1); // Average period in seconds
377
- node.runtime.tuneData.Ku = node.runtime.kp; // Ultimate gain
378
- node.runtime.kp = 0.6 * node.runtime.tuneData.Ku;
379
- node.runtime.ki = 2 * node.runtime.kp / node.runtime.tuneData.Tu;
380
- node.runtime.kd = node.runtime.kp * node.runtime.tuneData.Tu / 8;
381
- node.runtime.tuneMode = false;
382
- outputMsg.payload = node.runtime.result;
540
+ node.tuneData.Tu = (2 * periodSum) / (node.tuneData.peaks.length - 2); // Average full period
541
+
542
+ // Calculate ultimate gain (Ku) from relay amplitude and peak error amplitude
543
+ let peakErrors = node.tuneData.peaks.map(p => Math.abs(p.value));
544
+ let avgPeakError = peakErrors.reduce((a, b) => a + b, 0) / peakErrors.length;
545
+ node.tuneData.Ku = relayAmplitude / (avgPeakError || 0.1);
546
+
547
+ // Apply Ziegler-Nichols for conservative "no overshoot" response
548
+ node.kp = 0.2 * node.tuneData.Ku;
549
+ node.ki = 0.4 * node.kp / node.tuneData.Tu;
550
+ node.kd = 0.066 * node.kp * node.tuneData.Tu;
551
+
552
+ node.tuneMode = false;
553
+ outputMsg.payload = 0;
383
554
  outputMsg.tuneResult = {
384
- Kp: node.runtime.kp,
385
- Ki: node.runtime.ki,
386
- Kd: node.runtime.kd,
387
- Ku: node.runtime.tuneData.Ku,
388
- Tu: node.runtime.tuneData.Tu
555
+ method: 'relay-auto-tune',
556
+ Kp: node.kp,
557
+ Ki: node.ki,
558
+ Kd: node.kd,
559
+ Ku: node.tuneData.Ku,
560
+ Tu: node.tuneData.Tu,
561
+ oscillations: node.tuneData.oscillationCount
389
562
  };
390
- lastOutput = outputMsg.payload;
391
- node.status({
392
- fill: "green",
393
- shape: "dot",
394
- text: `tune: completed, Kp=${node.runtime.kp.toFixed(2)}, Ki=${node.runtime.ki.toFixed(2)}, Kd=${node.runtime.kd.toFixed(2)}`
395
- });
563
+ lastOutput = 0;
564
+ utils.setStatusOK(node, `tune: completed, Kp=${node.kp.toFixed(2)}, Ki=${node.ki.toFixed(2)}, Kd=${node.kd.toFixed(2)}`);
396
565
 
397
566
  send(outputMsg);
398
567
  if (done) done();
399
568
  return;
400
- }
401
- }
402
-
403
- // Integral term
404
- if (node.runtime.ki !== 0) {
405
- node.runtime.errorSum += interval * error;
406
- if (node.runtime.directAction) {
407
- if (-node.runtime.errorSum > maxInt) node.runtime.errorSum = -maxInt;
408
- else if (-node.runtime.errorSum < minInt) node.runtime.errorSum = -minInt;
409
569
  } else {
410
- node.runtime.errorSum = Math.min(Math.max(node.runtime.errorSum, minInt), maxInt);
570
+ // Still tuning - show progress
571
+ utils.setStatusBusy(node, `tune: measuring oscillations (${node.tuneData.oscillationCount} half-cycles)...`);
411
572
  }
412
573
  }
413
574
 
414
- // Gain calculations
415
- let pGain = node.runtime.kp * error;
416
- let intGain = node.runtime.ki !== 0 ? node.runtime.kp * node.runtime.ki * node.runtime.errorSum * interval : 0;
417
- let dRaw = (error - node.runtime.lastError) / interval;
418
- let dFiltered = node.runtime.kd !== 0 ? 0.1 * dRaw + 0.9 * node.runtime.lastDError : 0;
419
- let dGain = node.runtime.kd !== 0 ? node.runtime.kp * node.runtime.kd * dFiltered : 0;
420
-
421
- node.runtime.lastError = error;
422
- node.runtime.lastDError = dFiltered;
575
+ // ================================================================
576
+ // Integral Term (I in PID)
577
+ // Accumulates error over time to eliminate steady-state error
578
+ // ================================================================
579
+ // Integral term with anti-windup to prevent excessive accumulation
580
+ if (node.ki !== 0) {
581
+ // Add this interval's error contribution to accumulated error
582
+ node.errorSum += interval * error;
583
+ // Clamp integral to prevent wind-up (integrator saturation)
584
+ // Keeps errorSum within limits based on output range and gains
585
+ node.errorSum = Math.min(Math.max(node.errorSum, minInt / (node.kp * node.ki || 1)), maxInt / (node.kp * node.ki || 1));
586
+ }
423
587
 
424
- // Output calculation
588
+ // ================================================================
589
+ // Calculate the three PID terms
590
+ // P term: proportional to current error
591
+ // I term: proportional to accumulated error over time
592
+ // D term: proportional to rate of change of error (filtered to prevent noise)
593
+ // ================================================================
594
+ // P term (proportional) - immediate response to error
595
+ let pGain = node.kp * error;
596
+
597
+ // I term (integral) - eliminates steady-state error
598
+ // Note: Kp is NOT applied here (already in errorSum constraint calculation)
599
+ let intGain = node.ki !== 0 ? node.kp * node.ki * node.errorSum : 0;
600
+
601
+ // D term (derivative) - dampening, anticipates error changes
602
+ // Raw derivative can be noisy, so we filter it (0.1 new + 0.9 old = low-pass filter)
603
+ let dRaw = (error - node.lastError) / interval; // Rate of change of error
604
+ let dFiltered = node.kd !== 0 ? 0.1 * dRaw + 0.9 * node.lastDError : 0; // Low-pass filtered
605
+ let dGain = node.kd !== 0 ? node.kp * node.kd * dFiltered : 0;
606
+
607
+ // Store current values for next iteration's derivative calculation
608
+ node.lastError = error;
609
+ node.lastDError = dFiltered;
610
+
611
+ // ================================================================
612
+ // Combine PID terms and apply output limits
613
+ // ================================================================
614
+ // Sum all three terms (P + I + D) to get raw output
425
615
  let pv = pGain + intGain + dGain;
426
- //if (node.runtime.directAction) pv = -pv;
427
- pv = Math.min(Math.max(pv, node.runtime.outMin), node.runtime.outMax);
428
-
429
- // Rate of change limit
430
- if (node.runtime.maxChange !== 0) {
431
- if (node.runtime.result > pv) {
432
- node.runtime.result = (node.runtime.result - pv > node.runtime.maxChange) ? node.runtime.result - node.runtime.maxChange : pv;
616
+ // Note: directAction flag determines error calculation sign:
617
+ // - false (reverse action): error = setpoint - input (for heating applications)
618
+ // - true (direct action): error = input - setpoint (for cooling applications)
619
+ // Clamp output to min/max bounds (hard limits)
620
+ pv = Math.min(Math.max(pv, node.outMin), node.outMax);
621
+
622
+ // ================================================================
623
+ // Rate-of-change limiting (maxChange) - prevents sudden jumps
624
+ // Useful for preventing shock to equipment or actuators
625
+ // maxChange = units per second (e.g., 10 = max 10 units/sec ramp)
626
+ // ================================================================
627
+ if (node.maxChange !== 0) {
628
+ // Check how much output would change this interval
629
+ if (node.result > pv) {
630
+ // Output would decrease - limit ramp down
631
+ node.result = (node.result - pv > node.maxChange) ? node.result - node.maxChange : pv;
433
632
  } else {
434
- node.runtime.result = (pv - node.runtime.result > node.runtime.maxChange) ? node.runtime.result + node.runtime.maxChange : pv;
633
+ // Output would increase - limit ramp up
634
+ node.result = (pv - node.result > node.maxChange) ? node.result + node.maxChange : pv;
435
635
  }
436
636
  } else {
437
- node.runtime.result = pv;
637
+ // No rate limiting - use PID output directly
638
+ node.result = pv;
438
639
  }
439
-
440
- outputMsg.payload = node.runtime.result;
640
+
641
+ // Re-apply hard output limits after rate-of-change limiting
642
+ // Ensures final result never exceeds configured bounds regardless of maxChange ramp
643
+ node.result = Math.min(Math.max(node.result, node.outMin), node.outMax);
644
+
645
+ // Set output payload
646
+ outputMsg.payload = node.result;
647
+
648
+ // Safety check: ensure payload is never NaN
649
+ if (isNaN(outputMsg.payload) || !isFinite(outputMsg.payload)) {
650
+ outputMsg.payload = 0;
651
+ utils.setStatusError(node, "NaN detected, output forced to 0");
652
+ }
653
+
654
+ // ================================================================
655
+ // Include diagnostic information for debugging and monitoring
656
+ // Shows breakdown of all three PID terms and current state
657
+ // ================================================================
441
658
  outputMsg.diagnostics = {
442
- pGain,
443
- intGain,
444
- dGain,
445
- error,
446
- errorSum: node.runtime.errorSum,
447
- run: node.runtime.run,
448
- directAction: node.runtime.directAction,
449
- kp: node.runtime.kp,
450
- ki: node.runtime.ki,
451
- kd: node.runtime.kd
659
+ pGain, // Proportional term contribution
660
+ intGain, // Integral term contribution
661
+ dGain, // Derivative term contribution
662
+ error, // Current error value
663
+ errorSum: node.errorSum, // Accumulated integral error
664
+ run: node.run, // Controller enabled?
665
+ directAction: node.directAction, // Direct/Reverse action mode
666
+ kp: node.kp, // Proportional gain
667
+ ki: node.ki, // Integral gain
668
+ kd: node.kd // Derivative gain
452
669
  };
453
670
 
454
- const outputChanged = !lastOutput || lastOutput !== outputMsg.payload;
455
- if (outputChanged) {
456
- lastOutput = outputMsg.payload;
457
- node.status({
458
- fill: "blue",
459
- shape: "dot",
460
- text: `in: ${input.toFixed(2)}, out: ${node.runtime.result.toFixed(2)}, setpoint: ${node.runtime.setpoint.toFixed(2)}`
461
- });
462
- } else {
463
- node.status({
464
- fill: "blue",
465
- shape: "ring",
466
- text: `in: ${input.toFixed(2)}, out: ${node.runtime.result.toFixed(2)}, setpoint: ${node.runtime.setpoint.toFixed(2)}`
467
- });
468
- }
671
+ // ================================================================
672
+ // Update node status - show current state to user
673
+ // ================================================================
674
+ // Update status to show current input, output, and setpoint values
675
+ utils.setStatusChanged(node, `in: ${input.toFixed(2)}, out: ${node.result.toFixed(2)}, setpoint: ${node.setpoint.toFixed(2)}`);
676
+
677
+ // Track last output for comparison (optional, for flow logic)
678
+ lastOutput = outputMsg.payload;
469
679
 
680
+ // Send output message with payload and diagnostics
470
681
  send(outputMsg);
471
682
 
472
683
  if (done) done();