@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.
- package/nodes/accumulate-block.html +18 -8
- package/nodes/accumulate-block.js +39 -44
- package/nodes/add-block.html +1 -1
- package/nodes/add-block.js +18 -11
- package/nodes/alarm-collector.html +260 -0
- package/nodes/alarm-collector.js +292 -0
- package/nodes/alarm-config.html +129 -0
- package/nodes/alarm-config.js +126 -0
- package/nodes/alarm-service.html +96 -0
- package/nodes/alarm-service.js +142 -0
- package/nodes/analog-switch-block.js +25 -36
- package/nodes/and-block.js +44 -15
- package/nodes/average-block.js +46 -41
- package/nodes/boolean-switch-block.js +10 -28
- package/nodes/boolean-to-number-block.html +18 -5
- package/nodes/boolean-to-number-block.js +24 -16
- package/nodes/cache-block.js +24 -37
- package/nodes/call-status-block.html +91 -32
- package/nodes/call-status-block.js +398 -115
- package/nodes/changeover-block.html +5 -0
- package/nodes/changeover-block.js +167 -162
- package/nodes/comment-block.html +1 -1
- package/nodes/comment-block.js +14 -9
- package/nodes/compare-block.html +14 -4
- package/nodes/compare-block.js +23 -18
- package/nodes/contextual-label-block.html +5 -0
- package/nodes/contextual-label-block.js +6 -16
- package/nodes/convert-block.html +25 -39
- package/nodes/convert-block.js +31 -16
- package/nodes/count-block.html +11 -5
- package/nodes/count-block.js +34 -32
- package/nodes/delay-block.js +58 -53
- package/nodes/divide-block.js +43 -45
- package/nodes/edge-block.html +17 -10
- package/nodes/edge-block.js +43 -41
- package/nodes/enum-switch-block.js +6 -6
- package/nodes/frequency-block.html +6 -1
- package/nodes/frequency-block.js +64 -74
- package/nodes/global-getter.html +51 -15
- package/nodes/global-getter.js +43 -13
- package/nodes/global-setter.html +1 -1
- package/nodes/global-setter.js +40 -12
- package/nodes/history-buffer.html +96 -0
- package/nodes/history-buffer.js +461 -0
- package/nodes/history-collector.html +29 -1
- package/nodes/history-collector.js +37 -16
- package/nodes/history-config.html +13 -1
- package/nodes/history-service.html +84 -0
- package/nodes/history-service.js +52 -0
- package/nodes/hysteresis-block.html +5 -0
- package/nodes/hysteresis-block.js +13 -16
- package/nodes/interpolate-block.html +20 -2
- package/nodes/interpolate-block.js +39 -50
- package/nodes/join.html +78 -0
- package/nodes/join.js +78 -0
- package/nodes/latch-block.js +12 -14
- package/nodes/load-sequence-block.js +102 -110
- package/nodes/max-block.js +26 -26
- package/nodes/memory-block.js +57 -58
- package/nodes/min-block.js +26 -25
- package/nodes/minmax-block.js +35 -34
- package/nodes/modulo-block.js +45 -43
- package/nodes/multiply-block.js +43 -41
- package/nodes/negate-block.html +17 -7
- package/nodes/negate-block.js +25 -19
- package/nodes/network-point-read.html +128 -0
- package/nodes/network-point-read.js +230 -0
- package/nodes/{network-register.html → network-point-register.html} +94 -7
- package/nodes/{network-register.js → network-point-register.js} +18 -4
- package/nodes/network-point-write.html +149 -0
- package/nodes/network-point-write.js +222 -0
- package/nodes/network-service-bridge.html +131 -0
- package/nodes/network-service-bridge.js +376 -0
- package/nodes/network-service-read.html +81 -0
- package/nodes/{network-read.js → network-service-read.js} +4 -3
- package/nodes/{network-point-registry.html → network-service-registry.html} +19 -4
- package/nodes/{network-point-registry.js → network-service-registry.js} +7 -2
- package/nodes/network-service-write.html +89 -0
- package/nodes/{network-write.js → network-service-write.js} +3 -3
- package/nodes/nullify-block.js +13 -15
- package/nodes/on-change-block.html +17 -9
- package/nodes/on-change-block.js +49 -46
- package/nodes/oneshot-block.html +13 -10
- package/nodes/oneshot-block.js +57 -75
- package/nodes/or-block.js +44 -15
- package/nodes/pid-block.html +54 -4
- package/nodes/pid-block.js +459 -248
- package/nodes/priority-block.js +24 -35
- package/nodes/rate-limit-block.js +70 -72
- package/nodes/rate-of-change-block.html +33 -14
- package/nodes/rate-of-change-block.js +74 -62
- package/nodes/round-block.html +14 -9
- package/nodes/round-block.js +32 -25
- package/nodes/saw-tooth-wave-block.js +49 -76
- package/nodes/scale-range-block.html +12 -6
- package/nodes/scale-range-block.js +46 -39
- package/nodes/sine-wave-block.js +49 -57
- package/nodes/string-builder-block.js +6 -6
- package/nodes/subtract-block.js +38 -34
- package/nodes/thermistor-block.js +44 -44
- package/nodes/tick-tock-block.js +32 -32
- package/nodes/time-sequence-block.js +30 -42
- package/nodes/triangle-wave-block.js +49 -69
- package/nodes/tstat-block.js +34 -44
- package/nodes/units-block.html +90 -69
- package/nodes/units-block.js +22 -30
- package/nodes/utils.js +206 -3
- package/package.json +14 -6
- package/nodes/network-read.html +0 -56
- package/nodes/network-write.html +0 -65
package/nodes/pid-block.js
CHANGED
|
@@ -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
|
-
//
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
//
|
|
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
|
-
|
|
106
|
+
utils.setStatusError(node, "invalid message");
|
|
54
107
|
if (done) done();
|
|
55
108
|
return;
|
|
56
109
|
}
|
|
57
110
|
|
|
58
|
-
//
|
|
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
|
-
//
|
|
64
|
-
|
|
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
|
-
//
|
|
73
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
142
|
-
if (!isNaN(results[1])) node.
|
|
143
|
-
if (!isNaN(results[2])) node.
|
|
144
|
-
|
|
145
|
-
if (!isNaN(results[4])) node.
|
|
146
|
-
if (!isNaN(results[5])) node.
|
|
147
|
-
if (!isNaN(results[6])) node.
|
|
148
|
-
if (!isNaN(results[7])) node.
|
|
149
|
-
if (results[8] != null) node.
|
|
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 (
|
|
161
|
-
|
|
162
|
-
|
|
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.
|
|
168
|
-
|
|
169
|
-
node.
|
|
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 (
|
|
172
|
-
|
|
173
|
-
node.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
207
|
-
|
|
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
|
-
|
|
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
|
-
|
|
298
|
+
utils.setStatusError(node, `invalid ${msg.context}`);
|
|
216
299
|
if (done) done();
|
|
217
300
|
return;
|
|
218
301
|
}
|
|
219
|
-
node
|
|
220
|
-
|
|
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
|
-
|
|
308
|
+
utils.setStatusError(node, "invalid dbBehavior");
|
|
224
309
|
if (done) done();
|
|
225
310
|
return;
|
|
226
311
|
}
|
|
227
|
-
node.
|
|
228
|
-
|
|
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
|
-
|
|
318
|
+
utils.setStatusError(node, "invalid reset");
|
|
232
319
|
if (done) done();
|
|
233
320
|
return;
|
|
234
321
|
}
|
|
235
|
-
node.
|
|
236
|
-
node.
|
|
237
|
-
node.
|
|
238
|
-
node.
|
|
239
|
-
node.
|
|
240
|
-
node.
|
|
241
|
-
|
|
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
|
-
|
|
246
|
-
|
|
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.
|
|
252
|
-
node.
|
|
253
|
-
node.
|
|
254
|
-
node.
|
|
255
|
-
node
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
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
|
-
|
|
275
|
-
if (
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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
|
-
//
|
|
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.
|
|
284
|
-
node.
|
|
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.
|
|
391
|
+
setpoint: node.setpoint,
|
|
289
392
|
interval,
|
|
290
393
|
lastOutput,
|
|
291
|
-
run: node.
|
|
292
|
-
directAction: node.
|
|
293
|
-
kp: node.
|
|
294
|
-
ki: node.
|
|
295
|
-
kd: node.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
319
|
-
|
|
320
|
-
|
|
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.
|
|
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.
|
|
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
|
-
//
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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.
|
|
347
|
-
node.
|
|
449
|
+
if (node.ki !== storeki && node.ki !== 0 && storeki !== 0) {
|
|
450
|
+
node.errorSum = node.errorSum * storeki / node.ki;
|
|
348
451
|
}
|
|
349
|
-
kpkiConst = node.
|
|
350
|
-
minInt = kpkiConst === 0 ? 0 : (node.
|
|
351
|
-
maxInt = kpkiConst === 0 ? 0 : (node.
|
|
352
|
-
storekp = node.
|
|
353
|
-
storeki = node.
|
|
354
|
-
storeOutMin = node.
|
|
355
|
-
storeOutMax = node.
|
|
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
|
-
//
|
|
359
|
-
|
|
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
|
-
//
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
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.
|
|
368
|
-
|
|
369
|
-
node.runtime.tuneData.lastTrough = node.runtime.lastError;
|
|
525
|
+
node.tuneData.lastTrough = node.lastError;
|
|
526
|
+
node.tuneData.oscillationCount++;
|
|
370
527
|
}
|
|
371
|
-
|
|
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 =
|
|
374
|
-
periodSum += (node.
|
|
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.
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
node.
|
|
382
|
-
|
|
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
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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 =
|
|
391
|
-
node.
|
|
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
|
-
|
|
570
|
+
// Still tuning - show progress
|
|
571
|
+
utils.setStatusBusy(node, `tune: measuring oscillations (${node.tuneData.oscillationCount} half-cycles)...`);
|
|
411
572
|
}
|
|
412
573
|
}
|
|
413
574
|
|
|
414
|
-
//
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
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
|
-
//
|
|
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
|
-
//
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
//
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
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
|
-
|
|
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
|
-
|
|
637
|
+
// No rate limiting - use PID output directly
|
|
638
|
+
node.result = pv;
|
|
438
639
|
}
|
|
439
|
-
|
|
440
|
-
|
|
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.
|
|
447
|
-
run: node.
|
|
448
|
-
directAction: node.
|
|
449
|
-
kp: node.
|
|
450
|
-
ki: node.
|
|
451
|
-
kd: node.
|
|
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
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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();
|