@janart19/node-red-fusebox
Version:
A comprehensive collection of custom nodes for interfacing with Fusebox automation controllers - data streams, energy management, and utilities
318 lines (259 loc) • 12.5 kB
JavaScript
// Implementation of a PID Controller Node for Node-RED
// This node processes incoming messages to control a PID loop based on setpoint and actual values.
module.exports = function (RED) {
function PIDControllerNode(config) {
RED.nodes.createNode(this, config);
const node = this;
// Configuration
const outputTopic = config.outputTopic;
// Individual topic configuration
const actualTopic = config.actualTopic;
const setpointTopic = config.setpointTopic;
const pTopic = config.pTopic;
const iTopic = config.iTopic;
const dTopic = config.dTopic;
const upperLimitTopic = config.upperLimitTopic;
const lowerLimitTopic = config.lowerLimitTopic;
const invertTopic = config.invertTopic;
// Iteration control
const iterateOnTrigger = config.iterateOnTrigger || false;
const iterateTopic = config.iterateTopic;
// Create topics array for processing
const pidTopics = [actualTopic, setpointTopic, pTopic, iTopic, dTopic, upperLimitTopic, lowerLimitTopic, invertTopic, iterateTopic];
const INVALID_VALUES = ["", null, undefined];
// Initialize node state variables
node.actual_value = null;
node.setpoint = null;
node.Kp = null;
node.Ki = null;
node.Kd = null;
node.upper_limit = null;
node.lower_limit = null;
node.invert = false; // Default to false (heating mode)
// Miscellaneous state variables
node.prevError = 0;
node.prevProcessVariable = null;
node.integral = 0;
node.prevTime = Date.now() - 100; // Subtract 100ms to avoid division by zero
node.on("input", function (msg) {
try {
// If iteration is triggered by specific topic, check if this is the trigger
if (iterateOnTrigger && msg.topic === iterateTopic) {
// This is the trigger message - run PID calculation
runPIDCalculation(msg);
} else if (iterateOnTrigger) {
// Store the incoming message values but don't calculate yet
const processed = processMessage(msg);
if (processed === null) return; // Do not proceed
// Update status to show we're waiting for trigger
const validated = validateRequiredValues();
if (validated) {
node.status({ fill: "blue", shape: "dot", text: `Ready, waiting for trigger (${formatDate()})` });
}
} else {
// Normal mode - calculate on every input
runPIDCalculation(msg);
}
} catch (error) {
node.error(`PID Controller error: ${error.message}`, msg);
node.status({ fill: "red", shape: "dot", text: `Error: ${error.message}` });
}
});
// Run the PID calculation
function runPIDCalculation(msg) {
// Store the incoming message values
const processed = processMessage(msg);
if (processed === null) return; // Do not proceed
// Initialize prevProcessVariable if not set
if (node.prevProcessVariable === null) {
node.prevProcessVariable = node.actual_value;
}
node.currentTime = Date.now();
// Check if all required values are available
const validated = validateRequiredValues();
if (!validated) return; // Do not proceed
const output = calculate();
// Store the values updated during function execution
const error = node.invert ? node.actual_value - node.setpoint : node.setpoint - node.actual_value;
node.prevError = error;
node.prevProcessVariable = node.actual_value;
node.prevTime = node.currentTime;
// Return the message with the output and metadata
const metadata = {
actual_value: node.actual_value,
setpoint: node.setpoint,
Kp: node.Kp,
Ki: node.Ki,
Kd: node.Kd,
upper_limit: node.upper_limit,
lower_limit: node.lower_limit,
invert: node.invert,
integral: node.integral,
prevError: node.prevError,
prevProcessVariable: node.prevProcessVariable
};
const outputMsg = createMsg(output, outputTopic, metadata, msg);
node.send(outputMsg);
}
// Calculate the PID output
function calculate() {
// Time difference in seconds
let dt = (node.currentTime - node.prevTime) / 1000; // Convert ms to seconds
// Calculate error with optional inversion for cooling mode
const error = node.invert ? node.actual_value - node.setpoint : node.setpoint - node.actual_value;
// Proportional term
let P = node.Kp * error;
// Integral term
node.integral += error * dt; // Accumulate the integral
// Derivative term based on process variable, not error
let D = (node.Kd * (node.actual_value - node.prevProcessVariable)) / dt;
// Compute the PID output
let output = P + node.Ki * node.integral + D;
// Apply output limits and handle windup prevention
if (output > node.upper_limit) {
output = node.upper_limit;
node.integral -= error * dt; // Prevent integral windup in the upward direction
} else if (output < node.lower_limit) {
output = node.lower_limit;
node.integral -= error * dt; // Prevent integral windup in the downward direction
}
output = parseFloat(output.toFixed(2)); // Round to 2 decimal places
node.status({ fill: "green", shape: "dot", text: `Output: ${output} (${formatDate()})` });
return output;
}
// ===============
// Helper functions
// ===============
/**
* Check if the specified object is an object (not an array or null)
*/
function isObject(obj) {
return obj !== null && typeof obj === "object" && !Array.isArray(obj);
}
/**
* Format the current date and time as DD/MM/YYYY HH:MM:SS
*/
function formatDate() {
const now = new Date();
return now.toLocaleString("en-GB", {
day: "2-digit",
month: "2-digit",
year: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
hour12: false // Use 24-hour format
}); // 'en-GB' locale for DD/MM/YYYY format
}
/**
* Create a message with the specified payload, topic, and metadata
*/
function createMsg(payload, topic = null, metadata = null, originalMsg = {}) {
const msg = { ...originalMsg };
msg.payload = payload;
delete msg.topic;
if (topic) msg.topic = topic;
if (metadata) msg.metadata = metadata;
return msg;
}
/**
* Process the incoming message and store the value(s) in context
* This node can parse messages in 3 different formats.
* Return null if the message is unknown
*/
function processMessage(msg) {
// Find the topic in the incoming message
const msgTopic = msg.topic;
const topicIndex = pidTopics.indexOf(msgTopic);
if (topicIndex !== -1) {
// Single topic format: msg = { topic: "topic-1", payload: 0.1 }
if (!isInvalidValue(msgTopic, msg.payload)) {
setNodePropertyByIndex(topicIndex, msg.payload);
}
} else {
// Check for object format: msg = { "topic-1": 0.1, "topic-2": 0.2, ...}
const matchingTopics = Object.keys(msg).filter((key) => pidTopics.includes(key));
if (matchingTopics.length > 0) {
setVariables(matchingTopics, msg);
} else if (isObject(msg.payload)) {
// Check for nested payload format: msg = { payload: { "topic-1": 0.1, "topic-2": 0.2, ...} }
const nestedMatchingTopics = Object.keys(msg.payload).filter((key) => pidTopics.includes(key));
if (nestedMatchingTopics.length > 0) {
setVariables(nestedMatchingTopics, msg.payload);
} else {
node.status({ fill: "grey", shape: "dot", text: `Unknown topic (${formatDate()})` });
return null;
}
} else {
node.status({ fill: "grey", shape: "dot", text: `Unknown topic (${formatDate()})` });
return null;
}
}
// Helper function to set node property by topic index
function setNodePropertyByIndex(index, value) {
switch (index) {
case 0:
node.actual_value = value;
break;
case 1:
node.setpoint = value;
break;
case 2:
node.Kp = value;
break;
case 3:
node.Ki = value;
break;
case 4:
node.Kd = value;
break;
case 5:
node.upper_limit = value;
break;
case 6:
node.lower_limit = value;
break;
case 7:
node.invert = Boolean(value);
break;
}
}
// Check if a value is missing or invalid
function isInvalidValue(topic, value) {
if (INVALID_VALUES.includes(value)) {
node.status({ fill: "red", shape: "dot", text: `Invalid value for ${topic} (${formatDate()})` });
return true; // Do not proceed if the value is missing
}
return false;
}
// Loop through the topics and store the values in node properties
function setVariables(topics = [], obj = {}) {
for (const t of topics) {
if (isInvalidValue(t, obj[t])) break;
const topicIndex = pidTopics.indexOf(t);
if (topicIndex !== -1) {
setNodePropertyByIndex(topicIndex, obj[t]);
}
}
}
}
/**
* In case of missing or invalid values, the function returns either true or false
*/
function validateRequiredValues() {
const requiredValues = [node.actual_value, node.setpoint, node.Kp, node.Ki, node.Kd, node.upper_limit, node.lower_limit];
// Check if all required values are available
if (requiredValues.some((value) => INVALID_VALUES.includes(value))) {
const missingCount = requiredValues.filter((value) => INVALID_VALUES.includes(value)).length;
node.status({ fill: "yellow", shape: "dot", text: `Waiting for topics: ${missingCount}/7 (${formatDate()})` });
return false;
}
return true;
}
// Clean up on node removal
node.on("close", function () {
node.status({});
});
}
RED.nodes.registerType("fusebox-pid-controller", PIDControllerNode);
};