@strato-automation/node-red-contrib-strato-automation
Version:
Strato Automation Official Node Red Library
380 lines (352 loc) • 19.9 kB
HTML
<script type="text/javascript">
RED.nodes.registerType('strato-pid', {
category: 'Strato Automation',
color: '#f3b567',
defaults: {
name: { value: "" },
setpoint: { value: 50.0 },
pb: { value: 100, required: true },
ti: { value: 10000, required: true },
td: { value: 0, required: true },
kp: { value: 1, required: true },
ki: { value: 0, required: true },
kd: { value: 0, required: true },
integral_default: { value: 0.5 },
bias: { value: 0, required: true },
enable: { value: 1, required: true },
disabled_op: { value: 0 },
k_mode: { value: false, required: true },
deadband: { value: 0, required: true },
min_output: { value: 0 },
max_output: { value: 100 },
exec_time: { value: 10 },
reverse_action: { value: false, required: true },
initialized: { value: false }
},
inputs: 1,
outputs: 1,
icon: "function.png",
paletteLabel: function () {
return "PID";
},
label: function () {
return this.name || "PID";
},
oneditprepare: function () {
var node = this;
var userEdited = {};
function markEdited(id) { userEdited[id] = true; }
$("#node-input-name, #node-input-setpoint, #node-input-bias, #node-input-kp, #node-input-ki, #node-input-kd, #node-input-pb, #node-input-ti, #node-input-td, #node-input-min_output, #node-input-max_output, #node-input-deadband, #node-input-enable, #node-input-disabled_op, #node-input-integral_default").on("change input", function () { markEdited(this.id); });
$("#node-input-reverse_action").on("change", function () { markEdited(this.id); });
$.getJSON("strato-pid/runtime/" + node.id)
.done(function (data) {
if (data) {
if (!userEdited["node-input-name"] && data.name !== undefined) $("#node-input-name").val(data.name || "");
if (!userEdited["node-input-setpoint"] && Number.isFinite(data.setpoint)) $("#node-input-setpoint").val(data.setpoint);
if (!userEdited["node-input-bias"] && Number.isFinite(data.bias)) $("#node-input-bias").val(data.bias);
if (!userEdited["node-input-kp"] && Number.isFinite(data.kp) && data.kp > 0) $("#node-input-kp").val(data.kp);
if (!userEdited["node-input-ki"] && Number.isFinite(data.ki) && data.ki >= 0) $("#node-input-ki").val(data.ki);
if (!userEdited["node-input-kd"] && Number.isFinite(data.kd) && data.kd >= 0) $("#node-input-kd").val(data.kd);
if (!userEdited["node-input-pb"] && Number.isFinite(data.pb) && data.pb > 0) $("#node-input-pb").val(data.pb);
if (!userEdited["node-input-ti"] && Number.isFinite(data.ti) && data.ti >= 0) $("#node-input-ti").val(data.ti);
if (!userEdited["node-input-td"] && Number.isFinite(data.td) && data.td >= 0) $("#node-input-td").val(data.td);
if (!userEdited["node-input-min_output"] && Number.isFinite(data.min_output)) $("#node-input-min_output").val(data.min_output);
if (!userEdited["node-input-max_output"] && Number.isFinite(data.max_output)) $("#node-input-max_output").val(data.max_output);
if (!userEdited["node-input-deadband"] && Number.isFinite(data.deadband) && data.deadband >= 0) $("#node-input-deadband").val(data.deadband);
if (!userEdited["node-input-enable"] && data.enable !== undefined) $("#node-input-enable").val(data.enable);
if (!userEdited["node-input-disabled_op"] && Number.isFinite(data.disabled_op)) $("#node-input-disabled_op").val(data.disabled_op);
if (!userEdited["node-input-reverse_action"]) $("#node-input-reverse_action").prop("checked", !!data.reverse_action);
if (!userEdited["node-input-integral_default"] && Number.isFinite(data.integral_default)) $("#node-input-integral_default").val(data.integral_default);
syncPbFromKp();
syncKiFromTi();
syncTdFromKd();
applyingApi = false;
}
});
function syncPbFromKp() {
var v = parseFloat($("#node-input-kp").val());
if (!isNaN(v) && v > 0) {
var newVal = 100 / v;
if (parseFloat($("#node-input-pb").val()) !== newVal) $("#node-input-pb").val(newVal);
}
}
function syncKpFromPb() {
var v = parseFloat($("#node-input-pb").val());
if (!isNaN(v) && v > 0) {
var newVal = 100 / v;
if (parseFloat($("#node-input-kp").val()) !== newVal) $("#node-input-kp").val(newVal);
}
}
var syncLock = false;
function syncKiFromTi() {
if (syncLock) return;
var ti = parseFloat($("#node-input-ti").val());
var kp = parseFloat($("#node-input-kp").val());
if (!isNaN(ti) && !isNaN(kp) && kp > 0) {
var newVal = (ti === 0) ? 0 : (kp / ti);
if (parseFloat($("#node-input-ki").val()) !== newVal) {
syncLock = true;
$("#node-input-ki").val(newVal);
syncLock = false;
}
}
}
function syncTiFromKi() {
if (syncLock) return;
var ki = parseFloat($("#node-input-ki").val());
var kp = parseFloat($("#node-input-kp").val());
if (!isNaN(ki) && !isNaN(kp) && kp > 0) {
var newVal = (ki === 0) ? 0 : (kp / ki);
if (parseFloat($("#node-input-ti").val()) !== newVal) {
syncLock = true;
$("#node-input-ti").val(newVal);
syncLock = false;
}
}
}
function syncKdFromTd() {
var td = parseFloat($("#node-input-td").val());
var kp = parseFloat($("#node-input-kp").val());
if (!isNaN(td) && !isNaN(kp) && kp > 0 && td >= 0) {
var newVal = kp * td;
if (parseFloat($("#node-input-kd").val()) !== newVal) $("#node-input-kd").val(newVal);
}
}
function syncTdFromKd() {
var kd = parseFloat($("#node-input-kd").val());
var kp = parseFloat($("#node-input-kp").val());
if (!isNaN(kd) && !isNaN(kp) && kp > 0 && kd >= 0) {
var newVal = kd / kp;
if (parseFloat($("#node-input-td").val()) !== newVal) $("#node-input-td").val(newVal);
}
}
$("#node-input-pb").on("change", function() { syncKpFromPb(); syncKiFromTi(); syncKdFromTd(); });
$("#node-input-kp").on("change", function() { syncPbFromKp(); syncKiFromTi(); syncKdFromTd(); });
$("#node-input-ti").on("change", syncKiFromTi);
$("#node-input-ki").on("change", syncTiFromKi);
$("#node-input-td").on("change", syncKdFromTd);
$("#node-input-kd").on("change", syncTdFromKd);
syncPbFromKp();
syncKiFromTi();
syncTdFromKd();
$("#node-input-reset").on("click", function() {
$.post("strato-pid/reset/" + node.id)
.done(function() {
RED.notify.success("PID state reset");
})
.fail(function() {
RED.notify.error("Please deploy first", 0);
});
});
},
});
function _pidAddNode(RED, ref, config, dx, dy) {
var id = RED.util.generateId();
var n = RED.nodes.addNode(id, Object.assign({ id: id, x: ref.x + dx, y: ref.y + dy, z: ref.z }, config));
RED.editor.validateNode(n);
return n;
}
function _pidGroupRedraw() {
if (RED.view && RED.view.redraw) RED.view.redraw(true);
}
RED.events.on("nodes:add", function (addedNode) {
if (addedNode.type === "strato-pid" && !addedNode.initialized) {
var n = RED.nodes.node(addedNode.id);
if (!n) return;
setTimeout(function () {
try {
_pidAddNode(RED, n, {
type: 'input-topic',
name: "Setpoint",
topic: "in1"
}, -200, 50);
_pidAddNode(RED, n, {
type: 'input-topic',
name: "Input",
topic: "default"
}, -200, -50);
_pidGroupRedraw();
} catch (e) {
console.error("strato-pid: failed to spawn input nodes", e);
}
n.initialized = true;
}, 200);
}
});
</script>
<script type="text/html" data-template-name="strato-pid">
<div class="form-tips" style="margin-bottom: 20px;">
<b>Basic Settings</b>
</div>
<div class="form-row">
<label for="node-input-name"><i class="fa fa-bookmark"></i> Name</label>
<input type="text" id="node-input-name" placeholder="Optional name for this PID controller">
</div>
<div class="form-row">
<label for="node-input-setpoint"><i class="fa fa-bullseye"></i> Fallback Setpoint</label>
<input type="text" id="node-input-setpoint" placeholder="Initial setpoint value">
</div>
<div class="form-row">
<label for="node-input-reset"><i class="fa fa-refresh"></i> Reset PID State</label>
<button type="button" id="node-input-reset" class="red-ui-button" style="width: 70%;">
<i class="fa fa-refresh"></i> Reset Integral
</button>
</div>
<div class="form-tips" style="margin: 20px 0;">
<b>PID Parameters</b>
</div>
<div style="padding-left: 10px; border-left: 3px solid #ddd;">
<div class="form-row">
<label for="node-input-pb"><i class="fa fa-sliders"></i> Proportional Band [Pb] (%)</label>
<input type="text" id="node-input-pb" placeholder="e.g. 10 (Kp = 100% ÷ PB)">
</div>
<div class="form-row">
<label for="node-input-ti"><i class="fa fa-clock-o"></i> Integral Time [It] (s)</label>
<input type="text" id="node-input-ti" placeholder="0 = disable integral, or time constant (s)">
</div>
<div class="form-row">
<label for="node-input-td"><i class="fa fa-clock-o"></i> Derivative Time [Dt] (s)</label>
<input type="text" id="node-input-td" placeholder="Time constant for derivative effect">
</div>
</div>
<div class="form-tips" style="margin: 20px 0;">
<b>PID Gains (Alternative)</b>
</div>
<div style="padding-left: 10px; border-left: 3px solid #ddd;">
<div class="form-row">
<label for="node-input-kp"><i class="fa fa-line-chart"></i> Proportional Gain [Kp]</label>
<input type="text" id="node-input-kp" placeholder="e.g. 10 (Kp = 100% ÷ PB)">
</div>
<div class="form-row">
<label for="node-input-ki"><i class="fa fa-area-chart"></i> Integral Gain [Ki]</label>
<input type="text" id="node-input-ki" placeholder="Ki = Kp/It, or 60/It repeats/min">
</div>
<div class="form-row">
<label for="node-input-kd"><i class="fa fa-bar-chart"></i> Derivative Gain [Kd]</label>
<input type="text" id="node-input-kd" placeholder="">
</div>
</div>
<div class="form-tips" style="margin: 20px 0;">
<b>Output Settings</b>
</div>
<div style="padding-left: 10px; border-left: 3px solid #ddd;">
<div class="form-row">
<label for="node-input-bias"><i class="fa fa-balance-scale"></i> Bias</label>
<input type="text" id="node-input-bias" placeholder="Output bias value">
</div>
<div class="form-row">
<label for="node-input-min_output"><i class="fa fa-arrow-down"></i> Minimum Output</label>
<input type="text" id="node-input-min_output" placeholder="Minimum output value">
</div>
<div class="form-row">
<label for="node-input-max_output"><i class="fa fa-arrow-up"></i> Maximum Output</label>
<input type="text" id="node-input-max_output" placeholder="Maximum output value">
</div>
<div class="form-row">
<label for="node-input-deadband"><i class="fa fa-arrows-h"></i> Input Deadband</label>
<input type="text" id="node-input-deadband" placeholder="Control deadband">
</div>
</div>
<div class="form-tips" style="margin: 20px 0;">
<b>Control Settings</b>
</div>
<div style="padding-left: 10px; border-left: 3px solid #ddd;">
<div class="form-row">
<label for="node-input-enable"><i class="fa fa-power-off"></i> Enable State</label>
<input type="text" id="node-input-enable" placeholder="1 for enabled, 0 for disabled">
</div>
<div class="form-row">
<label for="node-input-disabled_op"><i class="fa fa-stop"></i> Disabled Output</label>
<input type="text" id="node-input-disabled_op" placeholder="Output when disabled">
</div>
<div class="form-row">
<label for="node-input-reverse_action"><i class="fa fa-exchange"></i> Reverse Action</label>
<input type="checkbox" id="node-input-reverse_action" style="width: auto;">
</div>
</div>
</script>
<script type="text/html" data-help-name="strato-pid">
<p>A sophisticated PID (Proportional-Integral-Derivative) controller node for precise control of real-world processes.</p>
<h3>Inputs</h3>
<dl class="message-properties">
<dt>Payload <span class="property-type">number</span></dt>
<dd>The current process value (PV) to be controlled</dd>
<dt class="optional">Topic <span class="property-type">string</span></dt>
<dd>
Special topics for parameter updates (case-insensitive; shortcuts in brackets):
<ul>
<li><code>input</code> [<code>in</code>, <code>default</code>] – Same as default payload (process value)</li>
<li><code>setpoint</code> [<code>Sp</code>, <code>in1</code>] – Update setpoint</li>
<li><code>proportional_band</code> [<code>Pb</code>] – Update proportional band</li>
<li><code>integral_time</code> [<code>It</code>] – Update integral time (0 = disable)</li>
<li><code>derivative_time</code> [<code>Dt</code>] – Update derivative time</li>
<li><code>proportional_gain</code> [<code>Kp</code>] – Update proportional gain</li>
<li><code>integral_gain</code> [<code>Ki</code>] – Update integral gain</li>
<li><code>derivative_gain</code> [<code>Kd</code>] – Update derivative gain</li>
<li><code>bias</code> – Update output bias</li>
<li><code>enable</code> – Enable/disable control (msg.payload: 1 or 0)</li>
<li><code>disabled_output</code> – Output value when enable = 0</li>
<li><code>reset</code> – Reset PID state (clears integral accumulation and internal state)</li>
</ul>
<p><b>Trig-based execution:</b> The PID runs when a process value update arrives – e.g. topic <code>default</code> or <code>input</code> or <code>in</code> with payload = current temp. When the process value changes, the PID executes.</p>
</dd>
<dt class="optional">Enabled <span class="property-type">boolean</span></dt>
<dd>
Enable/disable the PID controller. Set to true or false (or 1/0). When disabled, the PID stops calculating and outputs the disabled_output value. Useful for disabling control during off-hours or unoccupied periods.
<br><br>
Example: <code>msg.enabled = false</code> to disable, <code>msg.enabled = true</code> to enable.
</dd>
</dl>
<h3>Outputs</h3>
<dl class="message-properties">
<dt>Payload <span class="property-type">number</span></dt>
<dd>The calculated output value (0-100 by default, scale from min_output to max_output)</dd>
<dt>Integral <span class="property-type">number</span></dt>
<dd>Current integral term value (this value is conserved each time you deploy – use "reset" topic or "Reset Integral" button in the menu to clear it)</dd>
<dt>Derivative <span class="property-type">number</span></dt>
<dd>Current derivative term value</dd>
</dl>
<p>All input parameters (setpoint, bias, proportional_gain, integral_gain, derivative_gain, proportional_band, integral_time, derivative_time, min_output, max_output, deadband, enable, disabled_output, reverse_action, integral_default) plus execution_time (computed Dt) are included in every output message for monitoring.</p>
<h3>Details</h3>
<p>This node implements a complete PID control algorithm with features including:</p>
<ul>
<li>Configurable P, I, and D terms</li>
<li>Anti-windup protection</li>
<li>Adjustable output range</li>
<li>Deadband control</li>
<li>Reverse action option</li>
<li>Enable/disable control</li>
<li>Trig-based execution: runs on trig input, Dt = present_trig − last_trig</li>
</ul>
<h3>Reset Functionality</h3>
<p>If the PID accumulates incorrect integral state (e.g., due to sensor faults or process disturbances), you can reset it by sending a message with topic: "reset". This will:</p>
<ul>
<li>Clear the integral accumulation (i_state = 0)</li>
<li>Reset last_error to 0</li>
<li>Reset output to bias (if enabled) or disabled_op (if disabled)</li>
<li>Clear saved persistent state</li>
<li>Trigger bumpless recalculation on next cycle</li>
</ul>
<p>Example: Use an inject node connected to the PID input with topic: "reset" to manually reset the controller state.</p>
<h3>Parameter Tuning</h3>
<p><b>Quick Start Guide:</b></p>
<ol>
<li>Start with P control only (set It=0 to disable integral, Dt=0 for no derivative)</li>
<li>Adjust Proportional Band for stable operation</li>
<li>Add Integral control by setting It > 0 (e.g. 100–10000 s)</li>
<li>Finally, add Derivative if needed (typically 15–25% of It)</li>
</ol>
<p><b>Integral time:</b> It=0 disables the integral term (P or PD control). This is the standard technician convention.</p>
<p><b>Derivative:</b> D term = Kd × d(error)/Dt. ISA form uses Kd = Kp × Dt so the derivative responds to the rate of change of error (and thus temperature when setpoint is constant).</p>
<h3>Status Indicators</h3>
<dl class="message-properties">
<dt>Green dot</dt>
<dd>Normal operation with PV near setpoint</dd>
<dt>Blue dot</dt>
<dd>Normal operation with PV in deadband</dd>
<dt>Yellow dot</dt>
<dd>PID disabled</dd>
<dt>Red dot</dt>
<dd>Error condition (bad PV)</dd>
</dl>
</script>