UNPKG

@strato-automation/node-red-contrib-strato-automation

Version:
380 lines (352 loc) 19.9 kB
<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 &gt; 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>