@janart19/node-red-fusebox
Version:
A collection of Fusebox-specific custom nodes for Node-RED
91 lines (90 loc) • 17.3 kB
JSON
{
"id": "0ec4daa3c00801e3",
"type": "subflow",
"name": "inverter control",
"info": "Calculate a new setpoint for the inverter, taking into account the limitations for the grid and the inverter.\r\nAlso limit the changes to the inverter setpoint to avoid grid power limit overshoots.\r\n\r\nThe power and setpoint values are bipolar - positive for import, negative for export.\r\nEven if the inverter setpoint is 0, the inverter always kicks in to suppress grid power limit overshoots!\r\n\r\nUse similar units (e.g. W or kW) for everything.\r\n\r\n### Input\r\nThis subflow needs the following 2 required and 5 optional values to be set.\r\n\r\nRequired values:\r\n - `ic/grid/actual`: actual grid power\r\n - `ic/inverter/setpoint`: actual inverter setpoint power\r\n\r\nOptional values:\r\n - `ic/grid/import/max`: maximum grid import power, positive value, default 1500\r\n - `ic/grid/export/max`: maximum grid export power, negative value, default -1500\r\n - `ic/inverter/import/max`: maximum inverter import power, positive value, default 1000\r\n - `ic/inverter/export/max`: maximum inverter export power, negative value, default -1000\r\n\r\nThis subflow can parse input messages in 3 different formats:\r\n- `msg = { topic: \"ic/grid/actual\", payload: 0.5 }`\r\n- `msg = { \"ic/grid/actual\": 0.5, \"ic/inverter/setpoint\": 5, ...}`\r\n- `msg = { payload: { \"ic/grid/actual\": 0.5, \"ic/inverter/setpoint\": 5, ...} }`\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 inverter setpoint. This value should then be written to the required data stream member.\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: 5.5,\r\n topic: \"ic/inverter/out\",\r\n metadata: {\r\n current_grid_power: 1,\r\n max_grid_import: 5,\r\n ...\r\n }\r\n}\r\n```\r\n",
"category": "fusebox",
"in": [
{
"x": 80,
"y": 100,
"wires": [
{
"id": "f563b59743d8ade1"
}
]
}
],
"out": [
{
"x": 490,
"y": 100,
"wires": [
{
"id": "f563b59743d8ade1",
"port": 0
}
]
}
],
"env": [
{
"name": "OUTPUT_TOPIC",
"type": "str",
"value": "ic/inverter/out",
"ui": {
"icon": "font-awesome/fa-comment",
"label": {
"en-US": "Output message topic"
},
"type": "input",
"opts": {
"types": ["str"]
}
}
}
],
"meta": {},
"color": "#E7E7AE",
"inputLabels": ["Topic-payload messages"],
"outputLabels": ["New inverter output value"],
"icon": "font-awesome/fa-flash",
"status": {
"x": 400,
"y": 160,
"wires": [
{
"id": "2c058685e627b8e8",
"port": 0
}
]
},
"flow": [
{
"id": "f563b59743d8ade1",
"type": "function",
"z": "0ec4daa3c00801e3",
"name": "inverter control",
"func": "// Variables this code can use: msg, env, context, flow, global, node\n\n// ===============\n// Inverter control (grid congestion)\n//\n// Calculate a new setpoint for the inverter, taking into account the limitations for the grid and the inverter\n// The power and setpoint values are bipolar - positive for import, negative for export! Use similar units (W or KW) for everything.\n// Even if the external setpoint is 0, the inverter always kicks in to suppress grid power limit overshoots!\n//\n// Last update: 08.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 // The grid power data stream, requires outlo, outhi, value, e.g. GRIPW.1\n \"ic/grid/actual\": \"current_grid_power\",\n \"ic/grid/import/max\": \"max_grid_import\",\n \"ic/grid/export/max\": \"max_grid_export\",\n\n // The inverter_in data stream, requires outlo, outhi, value, e.g. SETPW.1\n \"ic/inverter/setpoint\": \"external_setpoint\",\n \"ic/inverter/import/max\": \"max_inverter_import\",\n \"ic/inverter/export/max\": \"max_inverter_export\",\n\n // Other variables\n \"ic/debug\": \"debug\",\n\n // The inverter_out data stream, not used, but this is where the new setpoint should be written, e.g. SETPW.3\n \"ic/inverter/setpoint/new\": \"svc_inverter_out\",\n};\n\n// Initialize the self object to store the variables\nconst self = {};\n\n// Retrieve the previous stored values from context\nself.external_setpoint_prev = getContextVariable(\"external_setpoint\", 0); // to detect changes in the external setpoint\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.current_grid_power = getContextVariable(\"current_grid_power\");\nself.max_grid_import = getContextVariable(\"max_grid_import\", 1500);\nself.max_grid_export = -getContextVariable(\"max_grid_export\", -1500);\n\nself.max_inverter_import = getContextVariable(\"max_inverter_import\", 1000);\nself.max_inverter_export = -getContextVariable(\"max_inverter_export\", -1000);\n\nself.setpoint = getContextVariable(\"setpoint\", 0); // to detect changes in the external setpoint\nself.external_setpoint = getContextVariable(\"external_setpoint\", 0); // last returned setpoint\nself.on_inverter_limit = getContextVariable(\"on_inverter_limit\", 0); // 1 or -1 if limit hit\n\n// Check if all required values are available\nconst validated = validateRequiredValues(self);\nif (validated === false) return; // Do not proceed\n\nconst new_setpoint = adjustSetpoint(self, self.external_setpoint, self.current_grid_power);\n\n// Store the values updated during function execution\nsetContextVariable(\"setpoint\", self.setpoint);\nsetContextVariable(\"on_inverter_limit\", self.on_inverter_limit);\n\n// Return the message with the output and metadata\n// The new setpoint should then be written to the inverter, e.g. SETPW.3\nreturn createMsg(new_setpoint, OUTPUT_TOPIC, { ...self });\n\n// Calculate new setpoint based on external setpoint, limited by grid and inverter capabilities\nfunction adjustSetpoint(self = {}, external_setpoint, current_grid_power) {\n let ext_set_change, add_adjustment, new_setpoint;\n let headroom, headroom_import, headroom_export;\n\n if (external_setpoint !== self.external_setpoint_prev) {\n ext_set_change = external_setpoint - self.external_setpoint_prev;\n debug(`external_setpoint change ${ext_set_change} from ${self.external_setpoint_prev} to ${external_setpoint}`);\n } else {\n ext_set_change = 0;\n }\n\n if (current_grid_power > self.max_grid_import) {\n // above import limit\n add_adjustment = self.max_grid_import - current_grid_power;\n debug(`import power overshoot by ${-add_adjustment}`);\n } else if (current_grid_power < -self.max_grid_export) {\n // above export limit\n add_adjustment = -self.max_grid_export - current_grid_power;\n debug(`export power overshoot by ${add_adjustment}`);\n } else if (current_grid_power > 0 && current_grid_power < self.max_grid_import && self.setpoint < external_setpoint) {\n headroom = self.max_grid_import - current_grid_power;\n add_adjustment = Math.min(headroom, ext_set_change); // limit to the value of self.setpoint < external_setpoint\n debug(`add_adjustment ${add_adjustment} W, max of (headroom ${headroom}, ext_set_change ${ext_set_change})`);\n } else if (current_grid_power < 0 && current_grid_power > -self.max_grid_export && self.setpoint > external_setpoint) {\n // below export limit but setpoint adjusted\n headroom = -self.max_grid_export - current_grid_power;\n add_adjustment = Math.max(headroom, ext_set_change);\n debug(`add_adjustment ${add_adjustment} W, min of (headroom ${headroom}, ext_set_change ${ext_set_change})`);\n } else {\n // no adjustments based on current power vs limits needed\n add_adjustment = 0;\n new_setpoint = external_setpoint;\n }\n\n if (add_adjustment !== 0) {\n new_setpoint = self.setpoint + add_adjustment;\n debug(`new_setpoint ${new_setpoint} = setpoint ${self.setpoint} + add_adjustment ${add_adjustment}`);\n } else {\n new_setpoint = external_setpoint;\n debug(`no adjustment needed, new_setpoint ${new_setpoint} is the same as externally given`);\n }\n\n // Setpoint jump from limited state to unlimited must never exceed the headroom!\n headroom_import = self.max_grid_import - current_grid_power;\n headroom_export = -self.max_grid_export - current_grid_power;\n\n if (new_setpoint - self.setpoint > headroom_import) {\n new_setpoint = self.setpoint + headroom_import; // assume setpoint is negative\n debug(`new_setpoint ${new_setpoint}, change limited by grid import headroom ${headroom_import}!`);\n\n if (new_setpoint > external_setpoint) {\n new_setpoint = external_setpoint;\n debug(`FIX: new_setpoint ${new_setpoint} limited to external_setpoint ${external_setpoint} instead of ${self.setpoint + headroom_import}!`);\n }\n } else if (new_setpoint - self.setpoint < headroom_export) {\n new_setpoint = self.setpoint + headroom_export;\n debug(`new_setpoint ${new_setpoint}, change limited by grid export headroom ${headroom_export}!`);\n\n if (new_setpoint < external_setpoint) {\n new_setpoint = external_setpoint;\n debug(`FIX: new_setpoint ${new_setpoint} limited to external_setpoint ${external_setpoint} instead of ${self.setpoint + headroom_export}!`);\n }\n }\n\n // Ensure the new setpoint does not exceed the inverter's capabilities\n if (new_setpoint > self.max_inverter_import) {\n new_setpoint = self.max_inverter_import;\n self.on_inverter_limit = 1;\n debug(`new_setpoint ${new_setpoint} limited by inverter import limit!`);\n } else if (new_setpoint < -self.max_inverter_export) {\n new_setpoint = -self.max_inverter_export;\n self.on_inverter_limit = -1;\n debug(`new_setpoint ${new_setpoint} limited by inverter export limit!`);\n } else {\n self.on_inverter_limit = 0;\n }\n\n if (self.setpoint !== new_setpoint) {\n self.setpoint = new_setpoint; // may include cumulative adjustments\n debug(`new setpoint ${self.setpoint} is set, on_inverter_limit ${self.on_inverter_limit}`);\n }\n\n // Update the node status\n node.status({\n fill: self.on_inverter_limit === 0 ? \"green\" : \"yellow\",\n shape: \"dot\",\n text: `Output${self.on_inverter_limit === 0 ? \"\" : \" limited\"}: ${new_setpoint.toFixed(2)} (${formatDate()})`,\n });\n\n return new_setpoint;\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 max_grid_import = getContextVariable(\"max_grid_import\");\n const max_grid_export = getContextVariable(\"max_grid_export\");\n\n const max_inverter_import = getContextVariable(\"max_inverter_import\");\n const max_inverter_export = getContextVariable(\"max_inverter_export\");\n\n const requiredValues = [self.current_grid_power, self.external_setpoint];\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 // Additional check that export limits are negative and import limits are positive\n if ((max_grid_export !== null && max_grid_export > 0) || (max_inverter_export !== null && max_inverter_export > 0)) {\n node.status({ fill: \"red\", shape: \"dot\", text: `Export limits must be <= 0 (${formatDate()})` });\n return false;\n }\n\n if ((max_grid_import !== null && max_grid_import < 0) || (max_inverter_import !== null && max_inverter_import < 0)) {\n node.status({ fill: \"red\", shape: \"dot\", text: `Import limits must be >= 0 (${formatDate()})` });\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": "2c058685e627b8e8",
"type": "status",
"z": "0ec4daa3c00801e3",
"name": "",
"scope": null,
"x": 260,
"y": 160,
"wires": [[]]
}
]
}