UNPKG

@janart19/node-red-fusebox

Version:

A collection of Fusebox-specific custom nodes for Node-RED

89 lines (88 loc) 11.2 kB
{ "id": "6edcdd10545e3f9b", "type": "subflow", "name": "pid controller", "info": "Calculate the PID output based on the actual value, setpoint, and tuning parameters.\r\n\r\n### Input\r\nThis subflow needs the following 7 required topics to be set:\r\n - `pid/actual`: current value\r\n - `pid/setpoint`: expected value\r\n - `pid/coefficient/p`: P coefficient\r\n - `pid/coefficient/i`: I coefficient\r\n - `pid/coefficient/d`: D coefficient\r\n - `pid/limit/upper`: maximum output value\r\n - `pid/limit/lower`: minimum output value\r\n\r\nThis subflow can parse input messages in 3 different formats:\r\n- `msg = { topic: \"pid/actual\", payload: 0.5 }`\r\n- `msg = { \"pid/actual\": 0.5, \"pid/setpoint\": 1, ...}`\r\n- `msg = { payload: { \"pid/actual\": 0.5, \"pid/setpoint\": 1, ...} }`\r\n\r\n### Output\r\n\r\nA successful output message includes a `payload`, `metadata`, and optional `topic` values.\r\nThis necessitates that all required input parameters are present.\r\n\r\nThe `payload` key's value is the new calculated output.\r\nIn addition, a `metadata` object will be set, which includes any internal variable values, useful for debugging purposes.\r\nIf `OUTPUT_TOPIC` is defined, a `topic` key-value pair will also be set in the output message. \r\n\r\nExample output message: \r\n```\r\n{\r\n payload: 1.5,\r\n topic: \"pid/output\",\r\n metadata: {\r\n actual_value: 1,\r\n setpoint: 2,\r\n ...\r\n }\r\n}\r\n```\r\n", "category": "fusebox", "in": [ { "x": 80, "y": 100, "wires": [ { "id": "690df116bbcf7da8" } ] } ], "out": [ { "x": 400, "y": 100, "wires": [ { "id": "690df116bbcf7da8", "port": 0 } ] } ], "env": [ { "name": "OUTPUT_TOPIC", "type": "str", "value": "pid/output", "ui": { "icon": "font-awesome/fa-comment", "label": { "en-US": "Output message topic" }, "type": "input", "opts": { "types": ["str"] } } } ], "meta": {}, "color": "#E6E0F8", "icon": "font-awesome/fa-calculator", "status": { "x": 400, "y": 160, "wires": [ { "id": "c55bb2ab65ceb58b", "port": 0 } ] }, "flow": [ { "id": "690df116bbcf7da8", "type": "function", "z": "6edcdd10545e3f9b", "name": "PID controller", "func": "// Variables this code can use: msg, env, context, flow, global, node\n\n// ===============\n// PID controller\n// Calculate the PID output based on the actual value, setpoint, and tuning parameters\n// Last update: 07.11.2024\n// ===============\n\n// Initialize environment variables\nconst OUTPUT_TOPIC = env.get(\"OUTPUT_TOPIC\");\n\nconst INVALID_VALUES = [\"\", null, undefined];\n\n// Define topic-to-variable mappings\nconst TOPICS = {\n \"pid/actual\": \"actual_value\",\n \"pid/setpoint\": \"setpoint\",\n \"pid/coefficient/p\": \"Kp\",\n \"pid/coefficient/i\": \"Ki\",\n \"pid/coefficient/d\": \"Kd\",\n \"pid/limit/upper\": \"upper_limit\",\n \"pid/limit/lower\": \"lower_limit\",\n};\n\n// Initialize the self object to store the variables\nconst self = {};\n\n// Store the incoming message values in the context\nconst processed = processMessage(msg);\nif (processed === null) return; // Do not proceed\n\n// Retrieve the newest stored values from context\nself.actual_value = getContextVariable(\"actual_value\");\nself.setpoint = getContextVariable(\"setpoint\");\nself.Kp = getContextVariable(\"Kp\");\nself.Ki = getContextVariable(\"Ki\");\nself.Kd = getContextVariable(\"Kd\");\nself.upper_limit = getContextVariable(\"upper_limit\");\nself.lower_limit = getContextVariable(\"lower_limit\");\n\n// Retrieve the stored previous state values from context\nself.prevError = getContextVariable(\"prevError\", 0);\nself.prevProcessVariable = getContextVariable(\"prevProcessVariable\", self.actual_value);\nself.integral = getContextVariable(\"integral\", 0);\nself.prevTime = getContextVariable(\"prevTime\", Date.now() - 100); // Subtract 100ms to avoid division by zero\n\nself.currentTime = Date.now();\n\n// Check if all required values are available\nconst validated = validateRequiredValues(self);\nif (!validated) return; // Do not proceed\n\nconst output = calculate(self);\n\n// Store the values updated during function execution\nsetContextVariable(\"prevError\", self.setpoint - self.actual_value);\nsetContextVariable(\"prevProcessVariable\", self.actual_value);\nsetContextVariable(\"integral\", self.integral);\nsetContextVariable(\"prevTime\", self.currentTime);\n\n// Return the message with the output and metadata\nreturn createMsg(output, OUTPUT_TOPIC, { ...self });\n\n// Calculate the PID output\nfunction calculate(self) {\n // Time difference in seconds\n let dt = (self.currentTime - self.prevTime) / 1000; // Convert ms to seconds\n\n // Proportional term\n let P = self.Kp * (self.setpoint - self.actual_value);\n\n // Integral term\n self.integral += (self.setpoint - self.actual_value) * dt; // Accumulate the integral\n\n // Derivative term based on process variable, not error\n let D = (self.Kd * (self.actual_value - self.prevProcessVariable)) / dt;\n\n // Compute the PID output\n let output = P + self.Ki * self.integral + D;\n\n // Apply output limits and handle windup prevention\n if (output > self.upper_limit) {\n output = self.upper_limit;\n self.integral -= (self.setpoint - self.actual_value) * dt; // Prevent integral windup in the upward direction\n } else if (output < self.lower_limit) {\n output = self.lower_limit;\n self.integral -= (self.setpoint - self.actual_value) * dt; // Prevent integral windup in the downward direction\n }\n\n node.status({ fill: \"green\", shape: \"dot\", text: `Output: ${output.toFixed(2)} (${formatDate()})` });\n\n return output;\n}\n\n// ===============\n// Helper functions\n// Various functions to simplify the code\n// ===============\n\n/**\n * Check if the specified object is an object (not an array or null)\n */\nfunction isObject(obj) {\n return obj !== null && typeof obj === \"object\" && !Array.isArray(obj);\n}\n\n/**\n * Log a debug message if the flag is enabled\n */\nfunction debug(str) {\n const debug = getContextVariable(\"debug\", false);\n\n if (debug) {\n node.debug(str);\n }\n}\n\n/**\n * Format the current date and time as DD/MM/YYYY HH:MM:SS\n */\nfunction formatDate() {\n const now = new Date();\n\n return now.toLocaleString(\"en-GB\", {\n day: \"2-digit\",\n month: \"2-digit\",\n year: \"2-digit\",\n hour: \"2-digit\",\n minute: \"2-digit\",\n second: \"2-digit\",\n hour12: false, // Use 24-hour format\n }); // 'en-GB' locale for DD/MM/YYYY format\n}\n\n/**\n * Retrieve the value stored in context with the specified key\n */\nfunction getContextVariable(key, defaultValue = null) {\n return context.get(key) ?? defaultValue;\n}\n\n/**\n * Store the specified value in context with the specified key\n */\nfunction setContextVariable(key, value) {\n if (INVALID_VALUES.includes(value)) {\n node.status({ fill: \"red\", shape: \"dot\", text: `Invalid value for ${key} (${formatDate()})` });\n return;\n }\n\n context.set(key, value);\n}\n\n/**\n * Create a message with the specified payload, topic, and metadata\n */\nfunction createMsg(payload, topic = null, metadata = null) {\n msg.payload = payload;\n\n delete msg.topic;\n if (topic) msg.topic = topic;\n if (metadata) msg.metadata = metadata;\n\n return msg;\n}\n\n/**\n * Process the incoming message and store the value(s) in context\n * This node can parse messages in 3 different formats.\n * Return null if the message is unknown\n */\nfunction processMessage(msg) {\n // Find the topic in the incoming message\n const topic1 = msg.topic ? TOPICS[msg.topic] : null; // msg = { topic: \"topic-1\", payload: 0.1 }\n const topic2 = msg ? Object.keys(msg).filter((key) => TOPICS.hasOwnProperty(key)) : []; // msg = { \"topic-1\": 0.1, \"topic-2\": 0.2, ...}\n const topic3 = isObject(msg.payload) ? Object.keys(msg.payload).filter((key) => TOPICS.hasOwnProperty(key)) : []; // msg = { payload: { \"topic-1\": 0.1, \"topic-2\": 0.2, ...} }\n\n // Store the value in correct context variable (based on the topic)\n if (topic1) {\n if (!isInvalidValue(topic1, msg.payload)) {\n setContextVariable(topic1, msg.payload);\n }\n } else if (topic2.length > 0) {\n setVariables(topic2, msg);\n } else if (topic3.length > 0) {\n setVariables(topic3, msg.payload);\n } else {\n node.status({ fill: \"grey\", shape: \"dot\", text: `Unknown topic (${formatDate()})` });\n return null;\n }\n\n // Check if a value is missing or invalid\n function isInvalidValue(topic, value) {\n if (INVALID_VALUES.includes(value)) {\n node.status({ fill: \"red\", shape: \"dot\", text: `Invalid value for ${topic} (${formatDate()})` });\n return true; // Do not proceed if the value is missing\n }\n\n return false;\n }\n\n // Loop through the topics and store the values in context\n function setVariables(topics = [], obj = {}) {\n for (const t of topics) {\n if (isInvalidValue(t, obj[t])) break;\n setContextVariable(TOPICS[t], obj[t]);\n }\n }\n}\n\n/**\n * In case of missing or invalid values, the function returns either true or false\n */\nfunction validateRequiredValues(self = {}) {\n const requiredValues = [self.actual_value, self.setpoint, self.Kp, self.Ki, self.Kd, self.upper_limit, self.lower_limit];\n\n // Check if all required values are available\n if (requiredValues.some((value) => INVALID_VALUES.includes(value))) {\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: `Waiting for topics: ${requiredValues.filter((value) => INVALID_VALUES.includes(value)).length} (${formatDate()})`,\n });\n\n return false;\n }\n\n return true;\n}\n", "outputs": 1, "timeout": 0, "noerr": 0, "initialize": "", "finalize": "", "libs": [], "x": 240, "y": 100, "wires": [[]] }, { "id": "c55bb2ab65ceb58b", "type": "status", "z": "6edcdd10545e3f9b", "name": "", "scope": null, "x": 260, "y": 160, "wires": [[]] } ] }