From 290316f4fb4b153a416c611d39c11b02fc138424 Mon Sep 17 00:00:00 2001
From: sammyjo468 <127890142+sammyjo468@users.noreply.github.com>
Date: Wed, 4 Feb 2026 09:25:33 +0100
Subject: [PATCH 1/6] typo
---
node-red/02 strategy-self-consumption.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/node-red/02 strategy-self-consumption.json b/node-red/02 strategy-self-consumption.json
index 3897722..6e939c3 100644
--- a/node-red/02 strategy-self-consumption.json
+++ b/node-red/02 strategy-self-consumption.json
@@ -1 +1 @@
-[{"id":"2dc255585c9021ad","type":"tab","label":"Strategy Self-consumption v4.4.2","disabled":false,"info":"","env":[]},{"id":"ba3cab11cfa58369","type":"group","z":"2dc255585c9021ad","name":"Strategy start","style":{"fill":"#ffffff","label":true},"nodes":["653d5ecae07626a1","c685bb5d5b350a17","6333a8d8470cd4f5"],"x":54,"y":79,"w":212,"h":162},{"id":"481e254058a8227d","type":"group","z":"2dc255585c9021ad","name":"Battery solutions","style":{"label":true,"fill":"#ffffff"},"nodes":["188b6d74052d7b75","d6ba85caf21c6c7a","02843f3df46ffe5b"],"x":64,"y":1139,"w":202,"h":142},{"id":"4bd04882ac08f740","type":"group","z":"2dc255585c9021ad","name":"Bumpless operation","style":{"label":true,"stroke":"#bfdbef"},"nodes":["8319c2bf362f5d55","c07fd441a19ddcd6","751cc0061f48cf83","1526a33d800732ac"],"x":1614,"y":19,"w":252,"h":242},{"id":"fa53931ece8d9e5c","type":"group","z":"2dc255585c9021ad","name":"Bumpless operation - switching control modes","style":{"stroke":"#bfdbef","label":true},"nodes":["af9242d4df5098ea","2d4aaea3163ee0b0","3c7a17a46ed568bb","b67191747e0bfe44","bdcb744226755ff7","2a5a379c33bdc594","ce822e5f44a30bc2","a98d3ba5387274d6","e2f0589bab3add2b","85a1019efd05b4bd","bc18c2754c562a1a","332d68169bd129ff"],"x":1614,"y":279,"w":592,"h":289.5},{"id":"b9b7c6e5106b3a7c","type":"group","z":"2dc255585c9021ad","name":"Inputs","style":{"fill":"#7fb7df","label":true,"color":"#ffffff"},"nodes":["d4bd6010b2a82002","b753f4385c7d13bd"],"x":288,"y":13,"w":904,"h":234},{"id":"f9161a30652a1e37","type":"group","z":"2dc255585c9021ad","name":"Pre processing","style":{"fill":"#bfdbef","label":true,"color":"#ffffff"},"nodes":["289b3a4f3ed9895e","15a8430ec8fd32cd","085064404e672245"],"x":288,"y":273,"w":1104,"h":294},{"id":"e90c3db5ee8c6a36","type":"group","z":"2dc255585c9021ad","name":"Control","style":{"fill":"#7fb7df","label":true,"color":"#ffffff"},"nodes":["0538d5e767988695","81378689693e940b"],"x":288,"y":593,"w":924,"h":634},{"id":"ff7db384260f1b22","type":"group","z":"2dc255585c9021ad","name":"Unhandled exceptions","style":{"label":true,"stroke":"#d88a8a","color":"#d88a8a"},"nodes":["916c17ae8b44a945","768fa0dcf7e8e3b5","30ea27bc433988a5"],"x":54,"y":279,"w":182,"h":82},{"id":"d4bd6010b2a82002","type":"group","z":"2dc255585c9021ad","g":"b9b7c6e5106b3a7c","name":"PID control inputs","style":{"label":true,"fill":"#ffffff"},"nodes":["89c318c6c8728833","e5144d3a238687bb","8ce405166bb8ae24","ae7e3834fba26c76","c7386c2b9a7d1cc0","edbd6dfed6ba7652","8bc9658f77284e22","800b817e2d30700f","fd84a7d1648928c8"],"x":314,"y":59,"w":572,"h":162},{"id":"b753f4385c7d13bd","type":"group","z":"2dc255585c9021ad","g":"b9b7c6e5106b3a7c","name":"Advanced features","style":{"label":true,"fill":"#ffffff"},"nodes":["f6c435539e7250e3","fe2a55ed5fe6bed5","58b564f75d3c21c3"],"x":914,"y":39,"w":252,"h":182},{"id":"289b3a4f3ed9895e","type":"group","z":"2dc255585c9021ad","g":"f9161a30652a1e37","name":"","style":{"fill":"#ffffff","label":true},"nodes":["858221520cee3dc5","735a9b3b250cd260","cd0dc47e2ff890dd","7726f1429c179f68"],"x":314,"y":299,"w":292,"h":202},{"id":"15a8430ec8fd32cd","type":"group","z":"2dc255585c9021ad","g":"f9161a30652a1e37","name":"Derivative - incl. bumpless operation","style":{"label":true,"stroke":"#a4a4a4","fill":"#ffffff"},"nodes":["7976d4a42e873093","823be9b66feeb0da","72ac88c55f926b58","e002c8dde1136e44","700e6af7e2972644"],"x":614,"y":399,"w":752,"h":142},{"id":"085064404e672245","type":"group","z":"2dc255585c9021ad","g":"f9161a30652a1e37","name":"Processing required? ","style":{"label":true,"fill":"#ffffff"},"nodes":["ea326ab0246b8c13","deae32c2aaf84154","2f97f335f207820a"],"x":614,"y":299,"w":562,"h":82},{"id":"0538d5e767988695","type":"group","z":"2dc255585c9021ad","g":"e90c3db5ee8c6a36","name":"PID controller","style":{"label":true,"fill":"#ffffff","color":"#3f3f3f"},"nodes":["a826eba0ed0ac7fc","e0644847be635f58","ad085261cf8a2cc1","2dc42d364f58d67f","7ede0bd5857ee7a0","5370c30d8cdab4b8","ed568c00a0795773","e05c810323db1ceb","16582ddbc62d0305","303bb8cea00177f9","829124787188e159","d4422f8539cc8ecf","71d8d3b4f6ef9310","4563c0e2adab2fee","9de6d5774b6b0bfa","729b998cf3b87cc7","83a1a6be0e294782","3db366538fbac912","f41cc8bd3b060241","817e304f8bb80d64"],"x":314,"y":619,"w":872,"h":482},{"id":"81378689693e940b","type":"group","z":"2dc255585c9021ad","g":"e90c3db5ee8c6a36","name":"Distribution","style":{"label":true,"fill":"#ffffff","color":"#3f3f3f"},"nodes":["3acf8bf116f857e1","9197f431cd22f55c"],"x":314,"y":1119,"w":452,"h":82},{"id":"edbd6dfed6ba7652","type":"junction","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","x":460,"y":100,"wires":[["800b817e2d30700f"]]},{"id":"800b817e2d30700f","type":"junction","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","x":700,"y":100,"wires":[["8bc9658f77284e22","fd84a7d1648928c8"]]},{"id":"fd84a7d1648928c8","type":"junction","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","x":720,"y":120,"wires":[["e5144d3a238687bb"]]},{"id":"653d5ecae07626a1","type":"link in","z":"2dc255585c9021ad","g":"ba3cab11cfa58369","name":"Self-consumption","links":[],"x":160,"y":160,"wires":[["6333a8d8470cd4f5"]],"l":true},{"id":"c685bb5d5b350a17","type":"comment","z":"2dc255585c9021ad","g":"ba3cab11cfa58369","name":"Start (readme)","info":"# Setting up your battery strategy flow\n\n## Setup\nName the Link In in this start group exactly after the option configured in your\nselect_input.house_battery_strategy\n\nfound in the file:\ninput_select_house_battery_control.yaml\n\n### example\n`house_battery_strategy:\n name: House Battery Strategy\n options:\n - AcmE example`\n\nWill point to the flow containing a Link In node\ncalled \u0027AcmE example\u0027 (case sensitive).","x":150,"y":120,"wires":[]},{"id":"9b4c669af44fc4a8","type":"comment","z":"2dc255585c9021ad","name":"Home Battery Strategy","info":"The `Home Battery Start` flow will call this strategy flow\nby matching the strategy name with the Link In name.\n\nConfigure the Link In node in the Start group.\n\nDon\u0027t modify other parts of the Start and End groups.\nThey handle the calling and returning for you.","x":120,"y":40,"wires":[]},{"id":"8319c2bf362f5d55","type":"server-state-changed","z":"2dc255585c9021ad","g":"4bd04882ac08f740","name":"User Ki change","server":"176d29a.6f648d6","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["input_number.house_battery_control_ki"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":true,"ignorePrevStateUnknown":true,"ignorePrevStateUnavailable":true,"ignoreCurrentStateUnknown":true,"ignoreCurrentStateUnavailable":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"x":1720,"y":100,"wires":[["c07fd441a19ddcd6"]]},{"id":"c07fd441a19ddcd6","type":"function","z":"2dc255585c9021ad","g":"4bd04882ac08f740","name":"Integral term adjust","func":"// On change of Ki setting\nlet logdata = \"bumpless \";\nlet I_sum = flow.get(\"I_integral_sum\");\nlet Ki = msg.payload || 0; // Integral gain\nlet Ki_last = context.get(\"house_battery_control_ki_last\")||0;\n\n// if Ki was or has become zero\nif(Ki == 0 || Ki_last == 0){\n // passify the integral-sum\n flow.set(\"I_integral_sum\",0);\n context.set(\"house_battery_control_ki_last\", Ki);\n logdata += `to/from 0, Ki=${Ki_last} -\u003e ${Ki} | `;\n // done\n return { payload: logdata };\n}\n\n// change in Ki\nlet dKi = parseFloat(Ki/Ki_last);\nif(dKi \u003c= 0) dKi = 1; // ignore erroneous changes\n\n// keep I-term constant by compensating the integral-sum for the change in Ki\n// e.g. if Ki got 10% smaller, then integral-sum should increase 10%, to keep the I-term constant\nlet I_sum_new = I_sum / dKi;\nlogdata += `change of I: from ${Ki_last} to ${Ki} (${dKi * 100}%) | `;\n\n// OUTFLOW\nflow.set(\"I_integral_sum\", I_sum_new);\ncontext.set(\"house_battery_control_ki_last\", Ki);\n\n// OUTPUT\nreturn { payload: logdata };","outputs":1,"timeout":0,"noerr":0,"initialize":"// Code added here will be run once\n// whenever the node is started.\n\n// init last state\nlet Ki = flow.get(\"house_battery_control_ki\") || 0;\ncontext.set(\"house_battery_control_ki_last\", Ki);","finalize":"","libs":[],"x":1730,"y":160,"wires":[["1526a33d800732ac"]]},{"id":"751cc0061f48cf83","type":"comment","z":"2dc255585c9021ad","g":"4bd04882ac08f740","name":"Bumpless Ki changes","info":"Recalc I-term whenever Ki changes\n\nNote: always be mindful of making changes to control parameters of systems in operation.","x":1740,"y":60,"wires":[]},{"id":"1526a33d800732ac","type":"debug","z":"2dc255585c9021ad","g":"4bd04882ac08f740","name":"Logdata","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1700,"y":220,"wires":[]},{"id":"af9242d4df5098ea","type":"switch","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Check for custom/auto mode","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"Manual control","vt":"str"},{"t":"eq","v":"Marstek control","vt":"str"},{"t":"eq","v":"Full control","vt":"str"}],"checkall":"true","repair":false,"outputs":3,"x":1855,"y":520,"wires":[["2a5a379c33bdc594","2d4aaea3163ee0b0"],["2a5a379c33bdc594","2d4aaea3163ee0b0"],["2d4aaea3163ee0b0"]],"l":false},{"id":"2d4aaea3163ee0b0","type":"debug","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Master control switch","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":2060,"y":520,"wires":[]},{"id":"3c7a17a46ed568bb","type":"server-state-changed","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Master control mode","server":"176d29a.6f648d6","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["input_select.marstek_master_battery_mode"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"x":1730,"y":520,"wires":[["af9242d4df5098ea"]]},{"id":"b67191747e0bfe44","type":"api-call-service","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Integral PID to zero","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_i_term"],"labelId":[],"data":"{\"value\": \"0\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":2050,"y":360,"wires":[["bdcb744226755ff7"]]},{"id":"bdcb744226755ff7","type":"api-call-service","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Differential PID to zero","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_d_term"],"labelId":[],"data":"{\"value\": \"0\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":2060,"y":400,"wires":[["ce822e5f44a30bc2"]]},{"id":"2a5a379c33bdc594","type":"api-call-service","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Proportinal PID to zero","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_p_term"],"labelId":[],"data":"{\"value\": \"0\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":2060,"y":320,"wires":[["b67191747e0bfe44"]]},{"id":"ce822e5f44a30bc2","type":"api-call-service","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"PID Output to zero","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_pid_output"],"labelId":[],"data":"{\"value\": \"0\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":2050,"y":440,"wires":[["a98d3ba5387274d6"]]},{"id":"a98d3ba5387274d6","type":"change","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Integral sum to zero","rules":[{"t":"set","p":"I_integral_sum","pt":"flow","to":"0","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":2060,"y":480,"wires":[[]]},{"id":"89c318c6c8728833","type":"api-current-state","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"Target grid consumption","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_target_grid_consumption_in_w","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"string","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_target_grid_consumption_in_w","propertyType":"msg","value":"string","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":570,"y":120,"wires":[["800b817e2d30700f"]],"info":"Set at 0 to strive for zero consumption"},{"id":"58b564f75d3c21c3","type":"api-current-state","z":"2dc255585c9021ad","g":"b753f4385c7d13bd","name":"Hysteresis","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_hysteresis_in_w","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_hysteresis_in_w","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":1010,"y":120,"wires":[["f6c435539e7250e3"]]},{"id":"e5144d3a238687bb","type":"api-current-state","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"PID P-value","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_kp","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_kp","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":410,"y":180,"wires":[["8ce405166bb8ae24"]]},{"id":"8ce405166bb8ae24","type":"api-current-state","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"PID I-value","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_ki","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_ki","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":590,"y":180,"wires":[["ae7e3834fba26c76"]]},{"id":"ae7e3834fba26c76","type":"api-current-state","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"PID D-value","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_kd","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_kd","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":770,"y":180,"wires":[["fe2a55ed5fe6bed5"]]},{"id":"f6c435539e7250e3","type":"function","z":"2dc255585c9021ad","g":"b753f4385c7d13bd","name":"Advanced settings","func":"// 1. Hard coded advanced settings (flow level)\nflow.set(\"has_soc_charging_limiter\", true); // slows charging from 90% to 100% to improve battery life\nflow.set(\"has_reverse_priority_discharge\", true); // Prioritize discharging and charging the same battery when possible\nflow.set(\"master_gain\", 1); // Gain scheduling. Not Implemented!\n\n// 2. Configurable advanced settings (msg level)\nmsg.advanced_settings ||= {}; // creates \u0027advanced_settings\u0027 if it does net yet exist\nlet mas = msg.advanced_settings;\n\n// disable charge or dicharge solutions and changes them to stop solutions\nRED.util.setMessageProperty(msg,\"advanced_settings.discharge_disabled\", mas.discharge_disabled ?? false);\nRED.util.setMessageProperty(msg,\"advanced_settings.charge_disabled\", mas.charge_disabled ?? false);\n// The nullish coalescing (??) operator is a logical operator that returns its right-hand side operand when its left-hand side operand is null or undefined\n\n// 3. continue\nreturn msg;\n\n// Notes for home battery tinkerer friends:\n// Using RED.util.getMessageProperty / RED.util.setMessageProperty is more performant and safe.\n// If any part of the path (\"foo.bar\") is missing, it will return \u0027undefined\u0027 without throwing an error.","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1030,"y":180,"wires":[["735a9b3b250cd260"]]},{"id":"fe2a55ed5fe6bed5","type":"api-current-state","z":"2dc255585c9021ad","g":"b753f4385c7d13bd","name":"Idle time before Stop","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_idle_time","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"idle_time_minutes","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":1040,"y":80,"wires":[["58b564f75d3c21c3"]]},{"id":"858221520cee3dc5","type":"comment","z":"2dc255585c9021ad","g":"289b3a4f3ed9895e","name":"Setpoint ramping / damping","info":"","x":460,"y":460,"wires":[]},{"id":"735a9b3b250cd260","type":"api-current-state","z":"2dc255585c9021ad","g":"289b3a4f3ed9895e","name":"Error signal dampening","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_error_signal_dampening","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_error_signal_dampening","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":450,"y":340,"wires":[["cd0dc47e2ff890dd"]]},{"id":"cd0dc47e2ff890dd","type":"api-current-state","z":"2dc255585c9021ad","g":"289b3a4f3ed9895e","name":"Output signal dampening","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_pid_output_dampening","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_pid_output_dampening","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":450,"y":380,"wires":[["7726f1429c179f68"]]},{"id":"7726f1429c179f68","type":"function","z":"2dc255585c9021ad","g":"289b3a4f3ed9895e","name":"Set: Error signal","func":"// INPUTS\nlet P1_error_last = Number(context.get(\"p1_error_last\"))||0; // last error signal level in W\nlet dampening = Number(flow.get(\"house_battery_control_error_signal_dampening\")) || 0; // 0% - 100%\ndampening = dampening / 100; // to Number\n\n// Process Variable (PV) currently measured grid power, Setpoint (SP) desired grid power, set 0 for NoM\nvar P1_power = msg.grid_power; // PV: consume is positive, supply to grid is negative\nvar P1_setpoint = Number(RED.util.getMessageProperty(msg,\"house_target_grid_consumption_in_w\")); // SP: target value\n\n// Calc error signal\nlet P1_error_unfiltered = P1_setpoint - P1_power;\n\n// Simple averaging filter\nlet P1_error_filtered = (1 - dampening) * P1_error_unfiltered + (dampening) * P1_error_last;\n\n// OUTFLOW\ncontext.set(\"p1_error_last\", P1_error_filtered);\n\n// OUTPUT\nmsg.grid_error = P1_error_filtered; // W (watt), error signal = SP - PV\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":420,"y":420,"wires":[["ea326ab0246b8c13"]]},{"id":"7976d4a42e873093","type":"comment","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Bumpless target grid consumption","info":"The error value is used by default to calculate the derivative.\nWhen the target grid consumption (tgc) is changed, the system is takes the derivative of the process variable instead.\nThis prevents irratic behavior during sudden changes of the error or setpoint value.\n\ne.g. you change the setpoint from 0 to 100W \nThe error would make an instantanious jump and cause a huge derivative-term.\nThe pv stays continous and fluent.\n\nAfter 1 control tick the derivative is taken from error again.\nUntil the `tgc` is changed again.","x":780,"y":440,"wires":[]},{"id":"823be9b66feeb0da","type":"function","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Derivative PV","func":"var P1_power = msg.grid_power||0;\nvar P1_last_power = Number(context.get(\"p1_last_power\")) ||0;\n\n// PV derivative\nvar p1_derivative = P1_power - P1_last_power; // devided by unity seconds\n\n// OUTFLOW\ncontext.set(\"p1_last_power\", P1_power);\n\n// OUTPUT\nmsg.grid_derivative = p1_derivative;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1040,"y":500,"wires":[["700e6af7e2972644"]]},{"id":"72ac88c55f926b58","type":"switch","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Target grid consumption unchanged","property":"house_target_grid_consumption_in_w","propertyType":"flow","rules":[{"t":"eq","v":"","vt":"prev"},{"t":"neq","v":"","vt":"prev"}],"checkall":"false","repair":false,"outputs":2,"x":780,"y":480,"wires":[["e002c8dde1136e44"],["823be9b66feeb0da"]]},{"id":"e002c8dde1136e44","type":"function","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Derivative Error","func":"var P1_error = msg.grid_error||0;\nvar P1_last_error = Number(context.get(\"p1_last_error\")) ||0;\n\n// Error derivative\n// note: error = -PV\n// note: for simplicity we assume 1 second time steps\nlet p1_derivative = -(P1_error - P1_last_error); // devided by unity seconds\n\n// OUTFLOW\ncontext.set(\"p1_last_error\", P1_error);\n\n// OUTPUT\nmsg.grid_derivative = p1_derivative;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1040,"y":460,"wires":[["700e6af7e2972644"]]},{"id":"700e6af7e2972644","type":"function","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Derivative final value","func":"\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1240,"y":480,"wires":[["a826eba0ed0ac7fc"]]},{"id":"ea326ab0246b8c13","type":"function","z":"2dc255585c9021ad","g":"085064404e672245","name":"Deadband (15W)","func":"// Deadband\n// Stop processing if error is within .. Watt of target grid consumption.\n// This to reduce CPU loads \n\n// Logger\nconst log = global.get(\"logger\");\n\n// P1\nlet P1_error = msg.grid_error; // Watt\nlet P1_deadband = 15; // Watt\n\n// TODO\n// based on last \u0027assignable power\u0027 and current error, if (error \u003e assignable power) the system will never enter the deadband. And keep calculating each interation.\n// however since we don\u0027t know if the is the assignable power will change, until we calculate the Control Value / load balancer\n// we could end up in a deadlock if we simply stop execution here, based on assignable power alone.\n\n// Within Deadband\nif(Math.abs(P1_error) \u003c P1_deadband) {\n // stop processing\n node.status({fill:\"yellow\",shape:\"dot\",text:`Halt, ${Math.round(Math.abs(P1_error))} W`});\n log(this, `**Stopped processing** 🌿power saving, ${Math.round(Math.abs(P1_error))} W is within deadband of ${P1_deadband} W`);\n msg.payload = \u0027halted by deadband\u0027;\n return msg;\n} \n\n// Outside deadband\nnode.status({fill:\"green\",shape:\"dot\",text:`Pass, ${Math.round(Math.abs(P1_error))} W`});\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":730,"y":340,"wires":[["deae32c2aaf84154"]]},{"id":"a826eba0ed0ac7fc","type":"function","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Calculate corrections","func":"// Logger\nvar logdata = \"\"; // deprecated\nconst log = global.get(\"logger\");\n\n// Timing\nlet time_last = context.get(\u0027time_last\u0027) || Date.now(); // Milliseconds\nlet time_current = Date.now(); // Milliseconds\nlet time_delta = (time_current - time_last) / 1000; // Convert to seconds\n// note: due to inherent 1 sec intervals of P1 meter we omit dt terms. This is mostly for bug checking and optimizations.\n\n// Batteries\nvar B_power = msg.batteries_total_power; // charging is positive, discharging negative\nvar anti_windup_threshold = Number(flow.get(\"batteries_total_assignable_power\")) || 0; // max available charge/discharge power.\n\n// Process Variable (PV) currently measured grid power, Setpoint (SP) desired grid power, set 0 for NoM\nvar P1_power = msg.grid_power; // PV: consume is positive, supply to grid is negative\nvar P1_setpoint = flow.get(\"house_target_grid_consumption_in_w\"); // SP: target value\nvar P1_error = msg.grid_error; // W (watt), error signal = SP - PV\nvar P1_derivative = msg.grid_derivative; // Derivative of PV, not the error\n\n// PID values\nvar Kp = flow.get(\"house_battery_control_kp\") || 0.75; // Proportional gain\nvar Ki = flow.get(\"house_battery_control_ki\") || 0; // Integral gain Ki = Kp/Ti\nvar Kd = flow.get(\"house_battery_control_kd\") || 0; // Derivative gain Kd = Kp*Td\n\n// helpers\nvar I_integral_sum = context.get(\u0027I_integral_sum\u0027) || 0;\nvar Gm = flow.get(\"master_gain\")||1; // gain scheduling\n\n// optimizations\nvar hysteresis = flow.get(\"house_battery_control_hysteresis_in_w\"); // W (watt)\n\n// advanced settings\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\");\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\");\n\n// -- PID regulator --\n// Proportional term\nlet p_term = Gm * Kp * P1_error;\n\n// Integral term\nlet integral_max = 0;\nif (Ki \u003e 0) {\n // Integral term | integrate\n I_integral_sum += P1_error; \n // Integral term | apply anti-windup\n integral_max = anti_windup_threshold / Ki; // the anti-windup value is set to the current controlable range of the batteries, given by the previous load balancer calculations\n I_integral_sum = Math.min(Math.max(I_integral_sum, -integral_max), integral_max);\n} else {\n // If Ki is 0, keep everything 0\n I_integral_sum = 0;\n integral_max = 0;\n}\n\n// Integral term | the term\nlet i_term = Ki * I_integral_sum;\n\n// Integral term | explain\nlogdata += `anti_windup_threshold=${anti_windup_threshold}, integral_max=${integral_max}| `; // deprecated\n\n// Differential term\nlet d_term = Gm * Kd * (-P1_derivative); // omitted \u0027/time_delta\u0027. inherent P1 refresh fequency.\n\n// Total PID output\nvar PID_output = p_term + i_term + d_term; // W (watt), the control input for the battery packs\n\n// Charging or Discharging states\nvar B_was_charging = context.get(\"batteries_charging_last\") || false;\nvar B_is_charging = PID_output \u003e 0 ? true : false;\n\n// Explain\nlogdata += `U(${time_delta}s)[${PID_output}] = P(${Kp})[${p_term}] + I(${Ki})[${i_term}] + D(${Kd})[${d_term}] | `; // deprecated\nlog(this,`**PID Controller**`);\nlog(this,`Step size: ${time_delta} s`);\nif(Kp == 0 \u0026\u0026 Ki == 0 \u0026\u0026 Kd == 0) {\n log(this, `\u003cfont color=\"orange\"\u003e**Controller disabled**\u003c/font\u003e All PID gains are 0. Choose a PID preset or set gains \u003e 0.`,\"warn\");\n} else {\n log(this, `P-term: ${Math.floor(p_term)} W @ P-gain ${Kp}`);\n log(this, `I-term: ${Math.floor(i_term)} W @ I-gain ${Ki} (max. ${anti_windup_threshold} W)`);\n log(this, `D-term: ${Math.floor(d_term)} W @ D-gain ${Kd}`);\n log(this, `**PID ${B_is_charging ? \u0027Charge\u0027 : \u0027Discharge\u0027}:** ${Math.floor(p_term)} + ${Math.floor(i_term)} + ${Math.floor(d_term)} = **${Math.round(PID_output)} Watt**`);\n}\n\n// Only one of the advanced settings below can be active at once.\n// Advanced setting | discharge disabled\nif(isDischargeDisabled \u0026\u0026 !B_is_charging) {\n // log explain\n logdata += `Discharging disabled, PID unwound U(${time_delta})[0]=0+0+0 | `;\n log(this,\u0027**PID set to 0**. Discharge was disabled via `msg.advanced_settings`.\u0027);\n // Set all PID terms to 0 and unwind integral sum\n PID_output = 0;\n p_term = 0;\n i_term = 0;\n d_term = 0;\n I_integral_sum = 0; // unwind\n // maintain charge scenario\n B_is_charging = true;\n \n// Advanced setting | charge disabled\n} else if (isChargeDisabled \u0026\u0026 B_is_charging) {\n // log explain\n logdata += `Charging disabled, PID unwound U(${time_delta})[0]=0+0+0 | `;\n log(this,\u0027**PID set to 0**. Charge was disabled via `msg.advanced_settings`.\u0027);\n // Set all PID terms to 0 and unwind integral sum\n PID_output = 0;\n p_term = 0;\n i_term = 0;\n d_term = 0;\n I_integral_sum = 0; // unwind\n // maintain discharge scenario\n B_is_charging = false;\n\n// Advanced setting | hysteresis\n} else if \n// prevents excessive switching between (dis)charge mode around the zero point.\n// if new PID_output lies within hysteresis, it will not switch charge mode. \n// set hysteresis to 0, to apply no hysteresis\n(B_is_charging !== B_was_charging \u0026\u0026 Math.abs(PID_output) \u003c hysteresis){\n // log explain\n logdata += `Hysteresis prevented charge mode switch from ${B_was_charging ? \"charging\" : \"discharging\"} to ${B_is_charging ? \"charging\" : \"discharging\"} as ${Math.abs(PID_output)}W \u003c= ${hysteresis}(hyst) | `;\n log(this,`Hysteresis prevented charge mode switch from ${B_was_charging ? \"charging\" : \"discharging\"} to ${B_is_charging ? \"charging\" : \"discharging\"} as ${Math.abs(PID_output)}W \u003c= ${hysteresis}(hyst)`);\n // prevent negative charging or positive discharging values (not allowed)\n PID_output = (PID_output \u003c 0 \u0026\u0026 B_is_charging) || ((PID_output \u003e 0 \u0026\u0026 !B_is_charging)) ? 0 : PID_output;\n // maintain previous scenario\n B_is_charging = B_was_charging;\n} \n\n// save for next iteration\ncontext.set(\"batteries_charging_last\", B_is_charging); //boolean\n\n// OUTFLOW\ncontext.set(\"time_last\", Date.now());\ncontext.set(\"I_integral_sum\", I_integral_sum);\n\n// OUTPUT\nRED.util.setMessageProperty(msg, \"batteries_charging\", B_is_charging, true);\nRED.util.setMessageProperty(msg, \"I_integral_sum\", I_integral_sum,true);\nmsg.payload = Number(PID_output);\n\n\nreturn [msg, // payload = PID_output\n { payload: Number(P1_error)},\n { payload: parseFloat(p_term)},\n { payload: parseFloat(i_term)},\n { payload: parseFloat(d_term)},\n { payload: B_is_charging},\n { payload: logdata }];","outputs":7,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":440,"y":700,"wires":[["71d8d3b4f6ef9310"],["ad085261cf8a2cc1"],["3db366538fbac912"],["9de6d5774b6b0bfa"],["f41cc8bd3b060241"],["817e304f8bb80d64"],["e0644847be635f58"]],"inputLabels":["battery_array"]},{"id":"e0644847be635f58","type":"debug","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Logdata","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":720,"y":1060,"wires":[]},{"id":"ad085261cf8a2cc1","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Error Signal","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_error_signal"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":730,"y":760,"wires":[["829124787188e159"]]},{"id":"2dc42d364f58d67f","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"PID Output","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_pid_output"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":730,"y":700,"wires":[["16582ddbc62d0305"]]},{"id":"7ede0bd5857ee7a0","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Proportinal term","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_p_term"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":920,"y":820,"wires":[[]]},{"id":"5370c30d8cdab4b8","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Integral term","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_i_term"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":910,"y":880,"wires":[[]]},{"id":"ed568c00a0795773","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Differential term","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_d_term"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":920,"y":940,"wires":[[]]},{"id":"e05c810323db1ceb","type":"debug","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Charging","active":false,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload","targetType":"msg","statusVal":"payload","statusType":"auto","x":900,"y":1060,"wires":[]},{"id":"16582ddbc62d0305","type":"smooth","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"","property":"payload","action":"sd","count":"8","round":"","mult":"single","reduce":false,"x":900,"y":700,"wires":[["303bb8cea00177f9"]]},{"id":"303bb8cea00177f9","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"PID deviation","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_pid_output_deviation"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":1070,"y":700,"wires":[[]]},{"id":"829124787188e159","type":"smooth","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"","property":"payload","action":"sd","count":"8","round":"","mult":"single","reduce":false,"x":900,"y":760,"wires":[["d4422f8539cc8ecf"]]},{"id":"d4422f8539cc8ecf","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Error deviation","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_error_deviation"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":1080,"y":760,"wires":[[]]},{"id":"71d8d3b4f6ef9310","type":"function","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Output dampening","func":"// INPUT\nlet PID_unfiltered = msg.payload; // W Watt\nlet PID_last = context.get(\"PID_last\")||0; // W watt\nlet PID_damp = Number(flow.get(\"house_battery_control_pid_output_dampening\"))||0; // 0% - 100%\nPID_damp = PID_damp/100; // to Number\n\n// simple averaging filter\nlet PID_filtered = (1-PID_damp)*PID_unfiltered + (PID_damp)*PID_last;\n\n// OUTFLOW\ncontext.set(\"PID_last\", PID_filtered);\n\n// OUTPUT\nmsg.payload = Number(PID_filtered)\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":750,"y":660,"wires":[["4563c0e2adab2fee"]]},{"id":"4563c0e2adab2fee","type":"function","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Output failsafe","func":"// INFLOW\nlet maxCharge = msg.batteries_max_charge_power||undefined;\nlet maxDischarge = msg.batteries_max_discharge_power||undefined;\nlet isCharging = msg.batteries_charging||undefined;\nlet PID_output = msg.payload; // Watt\n\n// No failsafe\nif (maxCharge === undefined || maxDischarge == undefined) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"disabled\" });\n node.warn(`Output Failsafe is INACTIVE. Max charge or discharge limits are undefined`);\n // continue without safeguard\n return msg;\n} \n\n// limit\nlet limit = 0;\n\n// charging state unknown\nif(isCharging === undefined) {\n // limit on lowest of charge / discharge\n limit = Math.min(maxCharge,maxDischarge); \n}\n// charging state known\nlimit = isCharging ? maxCharge : maxDischarge;\n\n// Safe output\nif (PID_output \u003c= limit){\n node.status({ fill: \"green\", shape: \"dot\", text: \"safe\" });\n return msg;\n} \n\n// Unsafe, limit output\nnode.status({ fill: \"yellow\", shape: \"ring\", text: \"power limiting\" });\nmsg.payload = Number(limit);\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1080,"y":660,"wires":[["3acf8bf116f857e1","2dc42d364f58d67f"]]},{"id":"9de6d5774b6b0bfa","type":"rbe","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"on change","func":"rbe","gap":"","start":"","inout":"out","septopics":false,"property":"payload","topi":"topic","x":730,"y":880,"wires":[["5370c30d8cdab4b8"]]},{"id":"3acf8bf116f857e1","type":"function","z":"2dc255585c9021ad","g":"81378689693e940b","name":"Load distribution","func":"// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = msg.batteries; // Battery configuration as provided by `Home Batteries IO` flow\nvar isCharging = msg.batteries_charging; // Are we in a battery charging scenario?\nvar hasChargingLimiter = flow.get(\"has_soc_charging_limiter\") || false; // Battery life improvement\nvar hasReverseDischargePriority = flow.get(\"has_reverse_priority_discharge\") || false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\n\n// how much power do the batteries need to compensate?\nvar unassigned_power = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power} W`);\n\n// inits \nvar solution_array = []; // load distribution solutions\nvar batteries_total_assignable_power = 0; // Current max. available total (dis)charge power. Exceeding this max should trigger the anti-windup routine for the Integral terms\n\n// enums\nconst CMODE = {\n STOP: \"stop\", // Marstek batteries disconnect a relay when this is set or Power is 0W\n CHARGE: \"charge\",\n DISCHARGE: \"discharge\"\n}\n\n/**\n** Battery life improvement (slow charge near max SoC)\n* @param {number} soc\n* @param {number} max_power\n*/\nfunction chargingLimiter(soc, max_power) {\n // Must be greater than zero\n let limitedPower = Math.max(0, max_power); \n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc \u003e= 98) limitedPower = Math.min(300, limitedPower);\n else if (soc \u003e= 95) limitedPower = Math.min(500, limitedPower);\n else if (soc \u003e= 85) limitedPower = Math.min(1500, limitedPower);\n }\n return limitedPower;\n}\n\n/**\n* battery life improvement (disconnects battery only if the battery is not used for ... seconds)\n* @param {string | number} batteryID\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getStopSolution(batteryID) {\n // idle time, before stop is given to inverter relay.\n let time_idle_minimum = flow.get(\"idle_time_minutes\")*60*1000||0; // Milliseconds\n\n // retrieve last registered active times for each battery based on their ids\n let lasttime_active = context.get(\"lasttime_active\");\n \n // battery exists in register \n if(lasttime_active[batteryID] !== undefined){\n // timing\n let time_last = lasttime_active[batteryID]; // Milliseconds \n let time_now = Date.now(); // Milliseconds \n let time_idle = (time_now - time_last); // Milliseconds\n \n // battery is ready for STOP\n if (time_idle \u003e= time_idle_minimum) {\n log(this,`Battery ${batteryID} [STOP]: 0 Watt and disconnect relay.`);\n return {id: batteryID, mode: CMODE.STOP, power:0}; // STOP or 0 Watt disconnects relay\n }\n \n // battery is kept IDLE, while awaiting minimum inactive time\n log(this,`Battery ${batteryID} [IDLE]: 1 Watt for ${Math.round((time_idle_minimum-time_idle)/1000)}s. Keep relay engaged.`);\n return { id: batteryID, mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE, power: 1 }; // 1 Watt keeps battery IDLE, keeping relay engaged\n }\n\n // battery missing in register\n log(this,`Battery ${batteryID} [IDLE]: unknown last active time`);\n return getActiveSolution(batteryID, 1); // set a last active time and engage idle state\n}\n\n/**\n* Get battery solution and register a last active time.\n* @param {any} batteryID\n* @param {number} assigned_power\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getActiveSolution(batteryID, assigned_power){\n // register battery as active\n let lasttime_active = context.get(\"lasttime_active\")||[]; // last registered active times for each battery based on their ids\n lasttime_active[batteryID] = Date.now();\n context.set(\"lasttime_active\", lasttime_active);\n\n // solution\n return {\n id: batteryID,\n mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE,\n power: Math.round(assigned_power)\n };\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n batteries.reverse();\n}\n\n// -- LOAD BALANCER --\nbatteries.forEach((/** @type {{ id: any; soc: number; soc_min: number; soc_max: number; rs485: string; charging_max: number; discharging_max: any; }} */ battery) =\u003e {\n \n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; RS485: ${battery.rs485==\u0027enable\u0027?\u0027OK\u0027:\u0027Nok\u0027}`);\n\n // track if the battery should be skipped and why\n let skipReason = null;\n if (battery.rs485 !== \"enable\") {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because RS485 is not \u0027enable\u0027`;\n } else if (isCharging \u0026\u0026 battery.soc \u003e= battery.soc_max) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) \u003e= Max (${battery.soc_max}%) while charging`;\n } else if (!isCharging \u0026\u0026 battery.soc \u003c= battery.soc_min) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) \u003c= Min (${battery.soc_min}%) while discharging`;\n } else if (!isCharging \u0026\u0026 isDischargeDisabled) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n\n // battery NOT AVAIABLE, skip via soft stop\n if (skipReason !== null) {\n log(this, skipReason); // communicate reason\n let solution = getStopSolution(battery.id); // get a soft stop solution for this battery\n solution_array.push(solution); // add solution, do not update last active time.\n // next battery\n return; \n }\n\n // battery is AVAILABLE, assign power\n let battery_assignable_power = isCharging ? chargingLimiter(battery.soc, battery.charging_max) : battery.discharging_max;\n let assign = Math.min(unassigned_power, battery_assignable_power);\n \n // select solution\n var solution;\n if (assign \u003c= 0 ) {\n // no power solution\n solution = getStopSolution(battery.id); // a soft stop solution\n assign = 0;\n } else {\n // assigned power solution\n solution = getActiveSolution(battery.id, Math.round(assign));\n }\n solution_array.push(solution);\n \n // Explain: charge limiting\n if(unassigned_power \u003e battery_assignable_power \u0026\u0026 battery_assignable_power \u003c battery.charging_max) {\n log(this,`Charging limited to protect battery (${battery.soc}%): ${battery.charging_max}W -\u003e ${battery_assignable_power}W`);\n }\n // Explain: solution result\n log(this, `\u003cfont color=\"#009ac7\"\u003eBattery ${solution.id}\u003c/font\u003e: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? battery.charging_max : battery.discharging_max }W max.`)\n\n // remaining power to assign\n unassigned_power -= assign;\n batteries_total_assignable_power += Number(battery_assignable_power);\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n solution_array.reverse();\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n\n// OUTFLOW\nflow.set(\"batteries_total_assignable_power\", batteries_total_assignable_power); // this is set for the anti-windup calculation in the earlier \u0027calculate corrections\u0027 node, which will pick this up during next iteration.\n\n// OUTPUT\nreturn msg; // to Home Battery flow","outputs":1,"timeout":0,"noerr":0,"initialize":"// Code added here will be run once\n// whenever the node is started.\n\ncontext.set(\"lasttime_active\",[]);","finalize":"","libs":[],"x":420,"y":1160,"wires":[["9197f431cd22f55c","188b6d74052d7b75"]],"inputLabels":["isCharging (boolean)"]},{"id":"9197f431cd22f55c","type":"debug","z":"2dc255585c9021ad","g":"81378689693e940b","name":"Load distribution","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"solutions","targetType":"msg","statusVal":"","statusType":"auto","x":640,"y":1160,"wires":[]},{"id":"deae32c2aaf84154","type":"switch","z":"2dc255585c9021ad","g":"085064404e672245","name":"Pass or Halt","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"halted by deadband","vt":"str"},{"t":"neq","v":"halted by deadband","vt":"str"}],"checkall":"false","repair":false,"outputs":2,"x":910,"y":340,"wires":[["2f97f335f207820a"],["72ac88c55f926b58"]]},{"id":"188b6d74052d7b75","type":"link out","z":"2dc255585c9021ad","g":"481e254058a8227d","name":"Return","mode":"return","links":[],"x":225,"y":1240,"wires":[]},{"id":"d6ba85caf21c6c7a","type":"comment","z":"2dc255585c9021ad","g":"481e254058a8227d","name":"End (readme)","info":"Should return a solution_array of battery objects\n\n## battery object format\n`{{id: string|number, mode: string, power: number}} battery solution`\n- id is an arbitrary battery ID\n- mode is \"stop\", \"charge\", \"discharge\" for Marstek\n- power in Watts\n\n### example array\nreturn this type of solution_array via msg.solutions\n` \nlet solution_array = [];\nsolution_array.push({id:\"M1\", mode: \"charge\", power: 100}); // per battery\nreturn {solutions: solution_array};\n` ","x":170,"y":1180,"wires":[]},{"id":"2f97f335f207820a","type":"link out","z":"2dc255585c9021ad","g":"085064404e672245","name":"go to End","mode":"link","links":["02843f3df46ffe5b"],"x":1090,"y":340,"wires":[],"inputLabels":["Halt"],"l":true},{"id":"02843f3df46ffe5b","type":"link in","z":"2dc255585c9021ad","g":"481e254058a8227d","name":"link to End","links":["2f97f335f207820a"],"x":105,"y":1240,"wires":[["188b6d74052d7b75"]]},{"id":"729b998cf3b87cc7","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Is charging","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"","floorId":[],"areaId":[],"deviceId":[],"entityId":[],"labelId":[],"data":"","dataType":"jsonata","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":false,"domain":"","service":"","x":1070,"y":1000,"wires":[[]]},{"id":"83a1a6be0e294782","type":"function","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Config action","func":"const value = msg.payload;\nconst target = \"input_boolean.house_battery_control_is_charging\";\n\nmsg.payload = {\n \"action\": value?\"input_boolean.turn_on\":\"input_boolean.turn_off\",\n \"target\": {\n \"entity_id\": [target]\n },\n \"data\": {}\n}\n\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":910,"y":1000,"wires":[["729b998cf3b87cc7"]]},{"id":"c7386c2b9a7d1cc0","type":"switch","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"Target","property":"house_target_grid_consumption_in_w","propertyType":"msg","rules":[{"t":"nnull"},{"t":"else"}],"checkall":"false","repair":false,"outputs":2,"x":390,"y":120,"wires":[["edbd6dfed6ba7652"],["89c318c6c8728833"]]},{"id":"8bc9658f77284e22","type":"debug","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"Target","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"house_target_grid_consumption_in_w","targetType":"msg","statusVal":"house_target_grid_consumption_in_w","statusType":"auto","x":790,"y":100,"wires":[]},{"id":"e2f0589bab3add2b","type":"server-state-changed","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Strategy changes","server":"176d29a.6f648d6","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["input_text.house_battery_strategy_active_sub_strategy","input_select.house_battery_strategy"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"string","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"x":1720,"y":340,"wires":[["85a1019efd05b4bd"]]},{"id":"85a1019efd05b4bd","type":"switch","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"Full stop","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":1855,"y":340,"wires":[["2a5a379c33bdc594"]],"l":false},{"id":"bc18c2754c562a1a","type":"server-state-changed","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Full stop trigger","server":"176d29a.6f648d6","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["sensor.ev_is_charging"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"string","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"x":1720,"y":420,"wires":[["332d68169bd129ff"]]},{"id":"332d68169bd129ff","type":"switch","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"on","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":1855,"y":420,"wires":[["2a5a379c33bdc594"]],"l":false},{"id":"3db366538fbac912","type":"rbe","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"on change","func":"rbe","gap":"","start":"","inout":"out","septopics":false,"property":"payload","topi":"topic","x":730,"y":820,"wires":[["7ede0bd5857ee7a0"]]},{"id":"f41cc8bd3b060241","type":"rbe","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"on change","func":"rbe","gap":"","start":"","inout":"out","septopics":false,"property":"payload","topi":"topic","x":730,"y":940,"wires":[["ed568c00a0795773"]]},{"id":"817e304f8bb80d64","type":"rbe","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"on change","func":"rbe","gap":"","start":"","inout":"out","septopics":false,"property":"payload","topi":"topic","x":730,"y":1000,"wires":[["83a1a6be0e294782","e05c810323db1ceb"]]},{"id":"6333a8d8470cd4f5","type":"change","z":"2dc255585c9021ad","g":"ba3cab11cfa58369","name":"Trace","rules":[{"t":"set","p":"strategy.trace","pt":"msg","to":"[\t strategy.trace,\t {\t \"flow\": target,\t \"flow_settings\": advanced_settings,\t \"timestamp\": $now()\t } \t]","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":190,"y":200,"wires":[["c7386c2b9a7d1cc0"]],"info":"Attaches a breadcrumb trace to the msg\r\nso the user can reconstruct which route has\r\nbeen traveled"},{"id":"916c17ae8b44a945","type":"catch","z":"2dc255585c9021ad","g":"ff7db384260f1b22","name":"","scope":null,"uncaught":false,"x":95,"y":320,"wires":[["768fa0dcf7e8e3b5"]],"l":false},{"id":"768fa0dcf7e8e3b5","type":"function","z":"2dc255585c9021ad","g":"ff7db384260f1b22","name":"Unhandled Exception","func":"const handle = global.get(\u0027unhandledException\u0027);\n\nhandle(this);\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":145,"y":320,"wires":[["30ea27bc433988a5"]],"l":false},{"id":"30ea27bc433988a5","type":"link out","z":"2dc255585c9021ad","g":"ff7db384260f1b22","name":"link out 7","mode":"return","links":[],"x":195,"y":320,"wires":[]},{"id":"176d29a.6f648d6","type":"server","name":"Home Assistant","addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":"","connectionDelay":false,"cacheJson":false,"heartbeat":false,"heartbeatInterval":"","statusSeparator":"","enableGlobalContextStore":false},{"id":"4daf6e7bcce195d0","type":"global-config","env":[],"modules":{"node-red-contrib-home-assistant-websocket":"0.80.3","node-red-node-smooth":"0.1.2"}}]
+[{"id":"2dc255585c9021ad","type":"tab","label":"Strategy Self-consumption v4.4.2","disabled":false,"info":"","env":[]},{"id":"ba3cab11cfa58369","type":"group","z":"2dc255585c9021ad","name":"Strategy start","style":{"fill":"#ffffff","label":true},"nodes":["653d5ecae07626a1","c685bb5d5b350a17","6333a8d8470cd4f5"],"x":54,"y":79,"w":212,"h":162},{"id":"481e254058a8227d","type":"group","z":"2dc255585c9021ad","name":"Battery solutions","style":{"label":true,"fill":"#ffffff"},"nodes":["188b6d74052d7b75","d6ba85caf21c6c7a","02843f3df46ffe5b"],"x":64,"y":1139,"w":202,"h":142},{"id":"4bd04882ac08f740","type":"group","z":"2dc255585c9021ad","name":"Bumpless operation","style":{"label":true,"stroke":"#bfdbef"},"nodes":["8319c2bf362f5d55","c07fd441a19ddcd6","751cc0061f48cf83","1526a33d800732ac"],"x":1614,"y":19,"w":252,"h":242},{"id":"fa53931ece8d9e5c","type":"group","z":"2dc255585c9021ad","name":"Bumpless operation - switching control modes","style":{"stroke":"#bfdbef","label":true},"nodes":["af9242d4df5098ea","2d4aaea3163ee0b0","3c7a17a46ed568bb","b67191747e0bfe44","bdcb744226755ff7","2a5a379c33bdc594","ce822e5f44a30bc2","a98d3ba5387274d6","e2f0589bab3add2b","85a1019efd05b4bd","bc18c2754c562a1a","332d68169bd129ff"],"x":1614,"y":279,"w":592,"h":289.5},{"id":"b9b7c6e5106b3a7c","type":"group","z":"2dc255585c9021ad","name":"Inputs","style":{"fill":"#7fb7df","label":true,"color":"#ffffff"},"nodes":["d4bd6010b2a82002","b753f4385c7d13bd"],"x":288,"y":13,"w":904,"h":234},{"id":"f9161a30652a1e37","type":"group","z":"2dc255585c9021ad","name":"Pre processing","style":{"fill":"#bfdbef","label":true,"color":"#ffffff"},"nodes":["289b3a4f3ed9895e","15a8430ec8fd32cd","085064404e672245"],"x":288,"y":273,"w":1104,"h":294},{"id":"e90c3db5ee8c6a36","type":"group","z":"2dc255585c9021ad","name":"Control","style":{"fill":"#7fb7df","label":true,"color":"#ffffff"},"nodes":["0538d5e767988695","81378689693e940b"],"x":288,"y":593,"w":924,"h":634},{"id":"ff7db384260f1b22","type":"group","z":"2dc255585c9021ad","name":"Unhandled exceptions","style":{"label":true,"stroke":"#d88a8a","color":"#d88a8a"},"nodes":["916c17ae8b44a945","768fa0dcf7e8e3b5","30ea27bc433988a5"],"x":54,"y":279,"w":182,"h":82},{"id":"d4bd6010b2a82002","type":"group","z":"2dc255585c9021ad","g":"b9b7c6e5106b3a7c","name":"PID control inputs","style":{"label":true,"fill":"#ffffff"},"nodes":["89c318c6c8728833","e5144d3a238687bb","8ce405166bb8ae24","ae7e3834fba26c76","c7386c2b9a7d1cc0","edbd6dfed6ba7652","8bc9658f77284e22","800b817e2d30700f","fd84a7d1648928c8"],"x":314,"y":59,"w":572,"h":162},{"id":"b753f4385c7d13bd","type":"group","z":"2dc255585c9021ad","g":"b9b7c6e5106b3a7c","name":"Advanced features","style":{"label":true,"fill":"#ffffff"},"nodes":["f6c435539e7250e3","fe2a55ed5fe6bed5","58b564f75d3c21c3"],"x":914,"y":39,"w":252,"h":182},{"id":"289b3a4f3ed9895e","type":"group","z":"2dc255585c9021ad","g":"f9161a30652a1e37","name":"","style":{"fill":"#ffffff","label":true},"nodes":["858221520cee3dc5","735a9b3b250cd260","cd0dc47e2ff890dd","7726f1429c179f68"],"x":314,"y":299,"w":292,"h":202},{"id":"15a8430ec8fd32cd","type":"group","z":"2dc255585c9021ad","g":"f9161a30652a1e37","name":"Derivative - incl. bumpless operation","style":{"label":true,"stroke":"#a4a4a4","fill":"#ffffff"},"nodes":["7976d4a42e873093","823be9b66feeb0da","72ac88c55f926b58","e002c8dde1136e44","700e6af7e2972644"],"x":614,"y":399,"w":752,"h":142},{"id":"085064404e672245","type":"group","z":"2dc255585c9021ad","g":"f9161a30652a1e37","name":"Processing required? ","style":{"label":true,"fill":"#ffffff"},"nodes":["ea326ab0246b8c13","deae32c2aaf84154","2f97f335f207820a"],"x":614,"y":299,"w":562,"h":82},{"id":"0538d5e767988695","type":"group","z":"2dc255585c9021ad","g":"e90c3db5ee8c6a36","name":"PID controller","style":{"label":true,"fill":"#ffffff","color":"#3f3f3f"},"nodes":["a826eba0ed0ac7fc","e0644847be635f58","ad085261cf8a2cc1","2dc42d364f58d67f","7ede0bd5857ee7a0","5370c30d8cdab4b8","ed568c00a0795773","e05c810323db1ceb","16582ddbc62d0305","303bb8cea00177f9","829124787188e159","d4422f8539cc8ecf","71d8d3b4f6ef9310","4563c0e2adab2fee","9de6d5774b6b0bfa","729b998cf3b87cc7","83a1a6be0e294782","3db366538fbac912","f41cc8bd3b060241","817e304f8bb80d64"],"x":314,"y":619,"w":872,"h":482},{"id":"81378689693e940b","type":"group","z":"2dc255585c9021ad","g":"e90c3db5ee8c6a36","name":"Distribution","style":{"label":true,"fill":"#ffffff","color":"#3f3f3f"},"nodes":["3acf8bf116f857e1","9197f431cd22f55c"],"x":314,"y":1119,"w":452,"h":82},{"id":"edbd6dfed6ba7652","type":"junction","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","x":460,"y":100,"wires":[["800b817e2d30700f"]]},{"id":"800b817e2d30700f","type":"junction","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","x":700,"y":100,"wires":[["8bc9658f77284e22","fd84a7d1648928c8"]]},{"id":"fd84a7d1648928c8","type":"junction","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","x":720,"y":120,"wires":[["e5144d3a238687bb"]]},{"id":"653d5ecae07626a1","type":"link in","z":"2dc255585c9021ad","g":"ba3cab11cfa58369","name":"Self-consumption","links":[],"x":160,"y":160,"wires":[["6333a8d8470cd4f5"]],"l":true},{"id":"c685bb5d5b350a17","type":"comment","z":"2dc255585c9021ad","g":"ba3cab11cfa58369","name":"Start (readme)","info":"# Setting up your battery strategy flow\n\n## Setup\nName the Link In in this start group exactly after the option configured in your\nselect_input.house_battery_strategy\n\nfound in the file:\ninput_select_house_battery_control.yaml\n\n### example\n`house_battery_strategy:\n name: House Battery Strategy\n options:\n - AcmE example`\n\nWill point to the flow containing a Link In node\ncalled \u0027AcmE example\u0027 (case sensitive).","x":150,"y":120,"wires":[]},{"id":"9b4c669af44fc4a8","type":"comment","z":"2dc255585c9021ad","name":"Home Battery Strategy","info":"The `Home Battery Start` flow will call this strategy flow\nby matching the strategy name with the Link In name.\n\nConfigure the Link In node in the Start group.\n\nDon\u0027t modify other parts of the Start and End groups.\nThey handle the calling and returning for you.","x":120,"y":40,"wires":[]},{"id":"8319c2bf362f5d55","type":"server-state-changed","z":"2dc255585c9021ad","g":"4bd04882ac08f740","name":"User Ki change","server":"176d29a.6f648d6","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["input_number.house_battery_control_ki"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":true,"ignorePrevStateUnknown":true,"ignorePrevStateUnavailable":true,"ignoreCurrentStateUnknown":true,"ignoreCurrentStateUnavailable":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"x":1720,"y":100,"wires":[["c07fd441a19ddcd6"]]},{"id":"c07fd441a19ddcd6","type":"function","z":"2dc255585c9021ad","g":"4bd04882ac08f740","name":"Integral term adjust","func":"// On change of Ki setting\nlet logdata = \"bumpless \";\nlet I_sum = flow.get(\"I_integral_sum\");\nlet Ki = msg.payload || 0; // Integral gain\nlet Ki_last = context.get(\"house_battery_control_ki_last\")||0;\n\n// if Ki was or has become zero\nif(Ki == 0 || Ki_last == 0){\n // passify the integral-sum\n flow.set(\"I_integral_sum\",0);\n context.set(\"house_battery_control_ki_last\", Ki);\n logdata += `to/from 0, Ki=${Ki_last} -\u003e ${Ki} | `;\n // done\n return { payload: logdata };\n}\n\n// change in Ki\nlet dKi = parseFloat(Ki/Ki_last);\nif(dKi \u003c= 0) dKi = 1; // ignore erroneous changes\n\n// keep I-term constant by compensating the integral-sum for the change in Ki\n// e.g. if Ki got 10% smaller, then integral-sum should increase 10%, to keep the I-term constant\nlet I_sum_new = I_sum / dKi;\nlogdata += `change of I: from ${Ki_last} to ${Ki} (${dKi * 100}%) | `;\n\n// OUTFLOW\nflow.set(\"I_integral_sum\", I_sum_new);\ncontext.set(\"house_battery_control_ki_last\", Ki);\n\n// OUTPUT\nreturn { payload: logdata };","outputs":1,"timeout":0,"noerr":0,"initialize":"// Code added here will be run once\n// whenever the node is started.\n\n// init last state\nlet Ki = flow.get(\"house_battery_control_ki\") || 0;\ncontext.set(\"house_battery_control_ki_last\", Ki);","finalize":"","libs":[],"x":1730,"y":160,"wires":[["1526a33d800732ac"]]},{"id":"751cc0061f48cf83","type":"comment","z":"2dc255585c9021ad","g":"4bd04882ac08f740","name":"Bumpless Ki changes","info":"Recalc I-term whenever Ki changes\n\nNote: always be mindful of making changes to control parameters of systems in operation.","x":1740,"y":60,"wires":[]},{"id":"1526a33d800732ac","type":"debug","z":"2dc255585c9021ad","g":"4bd04882ac08f740","name":"Logdata","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":1700,"y":220,"wires":[]},{"id":"af9242d4df5098ea","type":"switch","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Check for custom/auto mode","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"Manual control","vt":"str"},{"t":"eq","v":"Marstek control","vt":"str"},{"t":"eq","v":"Full control","vt":"str"}],"checkall":"true","repair":false,"outputs":3,"x":1855,"y":520,"wires":[["2a5a379c33bdc594","2d4aaea3163ee0b0"],["2a5a379c33bdc594","2d4aaea3163ee0b0"],["2d4aaea3163ee0b0"]],"l":false},{"id":"2d4aaea3163ee0b0","type":"debug","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Master control switch","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"true","targetType":"full","statusVal":"","statusType":"auto","x":2060,"y":520,"wires":[]},{"id":"3c7a17a46ed568bb","type":"server-state-changed","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Master control mode","server":"176d29a.6f648d6","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["input_select.marstek_master_battery_mode"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"x":1730,"y":520,"wires":[["af9242d4df5098ea"]]},{"id":"b67191747e0bfe44","type":"api-call-service","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Integral PID to zero","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_i_term"],"labelId":[],"data":"{\"value\": \"0\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":2050,"y":360,"wires":[["bdcb744226755ff7"]]},{"id":"bdcb744226755ff7","type":"api-call-service","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Differential PID to zero","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_d_term"],"labelId":[],"data":"{\"value\": \"0\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":2060,"y":400,"wires":[["ce822e5f44a30bc2"]]},{"id":"2a5a379c33bdc594","type":"api-call-service","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Proportional PID to zero","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_p_term"],"labelId":[],"data":"{\"value\": \"0\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":2060,"y":320,"wires":[["b67191747e0bfe44"]]},{"id":"ce822e5f44a30bc2","type":"api-call-service","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"PID Output to zero","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_pid_output"],"labelId":[],"data":"{\"value\": \"0\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":2050,"y":440,"wires":[["a98d3ba5387274d6"]]},{"id":"a98d3ba5387274d6","type":"change","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Integral sum to zero","rules":[{"t":"set","p":"I_integral_sum","pt":"flow","to":"0","tot":"str"}],"action":"","property":"","from":"","to":"","reg":false,"x":2060,"y":480,"wires":[[]]},{"id":"89c318c6c8728833","type":"api-current-state","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"Target grid consumption","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_target_grid_consumption_in_w","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"string","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_target_grid_consumption_in_w","propertyType":"msg","value":"string","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":570,"y":120,"wires":[["800b817e2d30700f"]],"info":"Set at 0 to strive for zero consumption"},{"id":"58b564f75d3c21c3","type":"api-current-state","z":"2dc255585c9021ad","g":"b753f4385c7d13bd","name":"Hysteresis","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_hysteresis_in_w","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_hysteresis_in_w","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":1010,"y":120,"wires":[["f6c435539e7250e3"]]},{"id":"e5144d3a238687bb","type":"api-current-state","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"PID P-value","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_kp","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_kp","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":410,"y":180,"wires":[["8ce405166bb8ae24"]]},{"id":"8ce405166bb8ae24","type":"api-current-state","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"PID I-value","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_ki","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_ki","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":590,"y":180,"wires":[["ae7e3834fba26c76"]]},{"id":"ae7e3834fba26c76","type":"api-current-state","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"PID D-value","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_kd","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_kd","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":770,"y":180,"wires":[["fe2a55ed5fe6bed5"]]},{"id":"f6c435539e7250e3","type":"function","z":"2dc255585c9021ad","g":"b753f4385c7d13bd","name":"Advanced settings","func":"// 1. Hard coded advanced settings (flow level)\nflow.set(\"has_soc_charging_limiter\", true); // slows charging from 90% to 100% to improve battery life\nflow.set(\"has_reverse_priority_discharge\", true); // Prioritize discharging and charging the same battery when possible\nflow.set(\"master_gain\", 1); // Gain scheduling. Not Implemented!\n\n// 2. Configurable advanced settings (msg level)\nmsg.advanced_settings ||= {}; // creates \u0027advanced_settings\u0027 if it does net yet exist\nlet mas = msg.advanced_settings;\n\n// disable charge or dicharge solutions and changes them to stop solutions\nRED.util.setMessageProperty(msg,\"advanced_settings.discharge_disabled\", mas.discharge_disabled ?? false);\nRED.util.setMessageProperty(msg,\"advanced_settings.charge_disabled\", mas.charge_disabled ?? false);\n// The nullish coalescing (??) operator is a logical operator that returns its right-hand side operand when its left-hand side operand is null or undefined\n\n// 3. continue\nreturn msg;\n\n// Notes for home battery tinkerer friends:\n// Using RED.util.getMessageProperty / RED.util.setMessageProperty is more performant and safe.\n// If any part of the path (\"foo.bar\") is missing, it will return \u0027undefined\u0027 without throwing an error.","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1030,"y":180,"wires":[["735a9b3b250cd260"]]},{"id":"fe2a55ed5fe6bed5","type":"api-current-state","z":"2dc255585c9021ad","g":"b753f4385c7d13bd","name":"Idle time before Stop","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_idle_time","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"idle_time_minutes","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":1040,"y":80,"wires":[["58b564f75d3c21c3"]]},{"id":"858221520cee3dc5","type":"comment","z":"2dc255585c9021ad","g":"289b3a4f3ed9895e","name":"Setpoint ramping / damping","info":"","x":460,"y":460,"wires":[]},{"id":"735a9b3b250cd260","type":"api-current-state","z":"2dc255585c9021ad","g":"289b3a4f3ed9895e","name":"Error signal dampening","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_error_signal_dampening","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_error_signal_dampening","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":450,"y":340,"wires":[["cd0dc47e2ff890dd"]]},{"id":"cd0dc47e2ff890dd","type":"api-current-state","z":"2dc255585c9021ad","g":"289b3a4f3ed9895e","name":"Output signal dampening","server":"176d29a.6f648d6","version":3,"outputs":1,"halt_if":"","halt_if_type":"str","halt_if_compare":"is","entity_id":"input_number.house_battery_control_pid_output_dampening","state_type":"str","blockInputOverrides":true,"outputProperties":[{"property":"payload","propertyType":"msg","value":"","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"entity"},{"property":"house_battery_control_pid_output_dampening","propertyType":"flow","value":"","valueType":"entityState"}],"for":"0","forType":"num","forUnits":"minutes","override_topic":false,"state_location":"payload","override_payload":"msg","entity_location":"data","override_data":"msg","x":450,"y":380,"wires":[["7726f1429c179f68"]]},{"id":"7726f1429c179f68","type":"function","z":"2dc255585c9021ad","g":"289b3a4f3ed9895e","name":"Set: Error signal","func":"// INPUTS\nlet P1_error_last = Number(context.get(\"p1_error_last\"))||0; // last error signal level in W\nlet dampening = Number(flow.get(\"house_battery_control_error_signal_dampening\")) || 0; // 0% - 100%\ndampening = dampening / 100; // to Number\n\n// Process Variable (PV) currently measured grid power, Setpoint (SP) desired grid power, set 0 for NoM\nvar P1_power = msg.grid_power; // PV: consume is positive, supply to grid is negative\nvar P1_setpoint = Number(RED.util.getMessageProperty(msg,\"house_target_grid_consumption_in_w\")); // SP: target value\n\n// Calc error signal\nlet P1_error_unfiltered = P1_setpoint - P1_power;\n\n// Simple averaging filter\nlet P1_error_filtered = (1 - dampening) * P1_error_unfiltered + (dampening) * P1_error_last;\n\n// OUTFLOW\ncontext.set(\"p1_error_last\", P1_error_filtered);\n\n// OUTPUT\nmsg.grid_error = P1_error_filtered; // W (watt), error signal = SP - PV\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":420,"y":420,"wires":[["ea326ab0246b8c13"]]},{"id":"7976d4a42e873093","type":"comment","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Bumpless target grid consumption","info":"The error value is used by default to calculate the derivative.\nWhen the target grid consumption (tgc) is changed, the system is takes the derivative of the process variable instead.\nThis prevents irratic behavior during sudden changes of the error or setpoint value.\n\ne.g. you change the setpoint from 0 to 100W \nThe error would make an instantanious jump and cause a huge derivative-term.\nThe pv stays continous and fluent.\n\nAfter 1 control tick the derivative is taken from error again.\nUntil the `tgc` is changed again.","x":780,"y":440,"wires":[]},{"id":"823be9b66feeb0da","type":"function","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Derivative PV","func":"var P1_power = msg.grid_power||0;\nvar P1_last_power = Number(context.get(\"p1_last_power\")) ||0;\n\n// PV derivative\nvar p1_derivative = P1_power - P1_last_power; // devided by unity seconds\n\n// OUTFLOW\ncontext.set(\"p1_last_power\", P1_power);\n\n// OUTPUT\nmsg.grid_derivative = p1_derivative;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1040,"y":500,"wires":[["700e6af7e2972644"]]},{"id":"72ac88c55f926b58","type":"switch","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Target grid consumption unchanged","property":"house_target_grid_consumption_in_w","propertyType":"flow","rules":[{"t":"eq","v":"","vt":"prev"},{"t":"neq","v":"","vt":"prev"}],"checkall":"false","repair":false,"outputs":2,"x":780,"y":480,"wires":[["e002c8dde1136e44"],["823be9b66feeb0da"]]},{"id":"e002c8dde1136e44","type":"function","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Derivative Error","func":"var P1_error = msg.grid_error||0;\nvar P1_last_error = Number(context.get(\"p1_last_error\")) ||0;\n\n// Error derivative\n// note: error = -PV\n// note: for simplicity we assume 1 second time steps\nlet p1_derivative = -(P1_error - P1_last_error); // devided by unity seconds\n\n// OUTFLOW\ncontext.set(\"p1_last_error\", P1_error);\n\n// OUTPUT\nmsg.grid_derivative = p1_derivative;\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1040,"y":460,"wires":[["700e6af7e2972644"]]},{"id":"700e6af7e2972644","type":"function","z":"2dc255585c9021ad","g":"15a8430ec8fd32cd","name":"Derivative final value","func":"\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1240,"y":480,"wires":[["a826eba0ed0ac7fc"]]},{"id":"ea326ab0246b8c13","type":"function","z":"2dc255585c9021ad","g":"085064404e672245","name":"Deadband (15W)","func":"// Deadband\n// Stop processing if error is within .. Watt of target grid consumption.\n// This to reduce CPU loads \n\n// Logger\nconst log = global.get(\"logger\");\n\n// P1\nlet P1_error = msg.grid_error; // Watt\nlet P1_deadband = 15; // Watt\n\n// TODO\n// based on last \u0027assignable power\u0027 and current error, if (error \u003e assignable power) the system will never enter the deadband. And keep calculating each interation.\n// however since we don\u0027t know if the is the assignable power will change, until we calculate the Control Value / load balancer\n// we could end up in a deadlock if we simply stop execution here, based on assignable power alone.\n\n// Within Deadband\nif(Math.abs(P1_error) \u003c P1_deadband) {\n // stop processing\n node.status({fill:\"yellow\",shape:\"dot\",text:`Halt, ${Math.round(Math.abs(P1_error))} W`});\n log(this, `**Stopped processing** 🌿power saving, ${Math.round(Math.abs(P1_error))} W is within deadband of ${P1_deadband} W`);\n msg.payload = \u0027halted by deadband\u0027;\n return msg;\n} \n\n// Outside deadband\nnode.status({fill:\"green\",shape:\"dot\",text:`Pass, ${Math.round(Math.abs(P1_error))} W`});\nreturn msg;\n","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":730,"y":340,"wires":[["deae32c2aaf84154"]]},{"id":"a826eba0ed0ac7fc","type":"function","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Calculate corrections","func":"// Logger\nvar logdata = \"\"; // deprecated\nconst log = global.get(\"logger\");\n\n// Timing\nlet time_last = context.get(\u0027time_last\u0027) || Date.now(); // Milliseconds\nlet time_current = Date.now(); // Milliseconds\nlet time_delta = (time_current - time_last) / 1000; // Convert to seconds\n// note: due to inherent 1 sec intervals of P1 meter we omit dt terms. This is mostly for bug checking and optimizations.\n\n// Batteries\nvar B_power = msg.batteries_total_power; // charging is positive, discharging negative\nvar anti_windup_threshold = Number(flow.get(\"batteries_total_assignable_power\")) || 0; // max available charge/discharge power.\n\n// Process Variable (PV) currently measured grid power, Setpoint (SP) desired grid power, set 0 for NoM\nvar P1_power = msg.grid_power; // PV: consume is positive, supply to grid is negative\nvar P1_setpoint = flow.get(\"house_target_grid_consumption_in_w\"); // SP: target value\nvar P1_error = msg.grid_error; // W (watt), error signal = SP - PV\nvar P1_derivative = msg.grid_derivative; // Derivative of PV, not the error\n\n// PID values\nvar Kp = flow.get(\"house_battery_control_kp\") || 0.75; // Proportional gain\nvar Ki = flow.get(\"house_battery_control_ki\") || 0; // Integral gain Ki = Kp/Ti\nvar Kd = flow.get(\"house_battery_control_kd\") || 0; // Derivative gain Kd = Kp*Td\n\n// helpers\nvar I_integral_sum = context.get(\u0027I_integral_sum\u0027) || 0;\nvar Gm = flow.get(\"master_gain\")||1; // gain scheduling\n\n// optimizations\nvar hysteresis = flow.get(\"house_battery_control_hysteresis_in_w\"); // W (watt)\n\n// advanced settings\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\");\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\");\n\n// -- PID regulator --\n// Proportional term\nlet p_term = Gm * Kp * P1_error;\n\n// Integral term\nlet integral_max = 0;\nif (Ki \u003e 0) {\n // Integral term | integrate\n I_integral_sum += P1_error; \n // Integral term | apply anti-windup\n integral_max = anti_windup_threshold / Ki; // the anti-windup value is set to the current controlable range of the batteries, given by the previous load balancer calculations\n I_integral_sum = Math.min(Math.max(I_integral_sum, -integral_max), integral_max);\n} else {\n // If Ki is 0, keep everything 0\n I_integral_sum = 0;\n integral_max = 0;\n}\n\n// Integral term | the term\nlet i_term = Ki * I_integral_sum;\n\n// Integral term | explain\nlogdata += `anti_windup_threshold=${anti_windup_threshold}, integral_max=${integral_max}| `; // deprecated\n\n// Differential term\nlet d_term = Gm * Kd * (-P1_derivative); // omitted \u0027/time_delta\u0027. inherent P1 refresh fequency.\n\n// Total PID output\nvar PID_output = p_term + i_term + d_term; // W (watt), the control input for the battery packs\n\n// Charging or Discharging states\nvar B_was_charging = context.get(\"batteries_charging_last\") || false;\nvar B_is_charging = PID_output \u003e 0 ? true : false;\n\n// Explain\nlogdata += `U(${time_delta}s)[${PID_output}] = P(${Kp})[${p_term}] + I(${Ki})[${i_term}] + D(${Kd})[${d_term}] | `; // deprecated\nlog(this,`**PID Controller**`);\nlog(this,`Step size: ${time_delta} s`);\nif(Kp == 0 \u0026\u0026 Ki == 0 \u0026\u0026 Kd == 0) {\n log(this, `\u003cfont color=\"orange\"\u003e**Controller disabled**\u003c/font\u003e All PID gains are 0. Choose a PID preset or set gains \u003e 0.`,\"warn\");\n} else {\n log(this, `P-term: ${Math.floor(p_term)} W @ P-gain ${Kp}`);\n log(this, `I-term: ${Math.floor(i_term)} W @ I-gain ${Ki} (max. ${anti_windup_threshold} W)`);\n log(this, `D-term: ${Math.floor(d_term)} W @ D-gain ${Kd}`);\n log(this, `**PID ${B_is_charging ? \u0027Charge\u0027 : \u0027Discharge\u0027}:** ${Math.floor(p_term)} + ${Math.floor(i_term)} + ${Math.floor(d_term)} = **${Math.round(PID_output)} Watt**`);\n}\n\n// Only one of the advanced settings below can be active at once.\n// Advanced setting | discharge disabled\nif(isDischargeDisabled \u0026\u0026 !B_is_charging) {\n // log explain\n logdata += `Discharging disabled, PID unwound U(${time_delta})[0]=0+0+0 | `;\n log(this,\u0027**PID set to 0**. Discharge was disabled via `msg.advanced_settings`.\u0027);\n // Set all PID terms to 0 and unwind integral sum\n PID_output = 0;\n p_term = 0;\n i_term = 0;\n d_term = 0;\n I_integral_sum = 0; // unwind\n // maintain charge scenario\n B_is_charging = true;\n \n// Advanced setting | charge disabled\n} else if (isChargeDisabled \u0026\u0026 B_is_charging) {\n // log explain\n logdata += `Charging disabled, PID unwound U(${time_delta})[0]=0+0+0 | `;\n log(this,\u0027**PID set to 0**. Charge was disabled via `msg.advanced_settings`.\u0027);\n // Set all PID terms to 0 and unwind integral sum\n PID_output = 0;\n p_term = 0;\n i_term = 0;\n d_term = 0;\n I_integral_sum = 0; // unwind\n // maintain discharge scenario\n B_is_charging = false;\n\n// Advanced setting | hysteresis\n} else if \n// prevents excessive switching between (dis)charge mode around the zero point.\n// if new PID_output lies within hysteresis, it will not switch charge mode. \n// set hysteresis to 0, to apply no hysteresis\n(B_is_charging !== B_was_charging \u0026\u0026 Math.abs(PID_output) \u003c hysteresis){\n // log explain\n logdata += `Hysteresis prevented charge mode switch from ${B_was_charging ? \"charging\" : \"discharging\"} to ${B_is_charging ? \"charging\" : \"discharging\"} as ${Math.abs(PID_output)}W \u003c= ${hysteresis}(hyst) | `;\n log(this,`Hysteresis prevented charge mode switch from ${B_was_charging ? \"charging\" : \"discharging\"} to ${B_is_charging ? \"charging\" : \"discharging\"} as ${Math.abs(PID_output)}W \u003c= ${hysteresis}(hyst)`);\n // prevent negative charging or positive discharging values (not allowed)\n PID_output = (PID_output \u003c 0 \u0026\u0026 B_is_charging) || ((PID_output \u003e 0 \u0026\u0026 !B_is_charging)) ? 0 : PID_output;\n // maintain previous scenario\n B_is_charging = B_was_charging;\n} \n\n// save for next iteration\ncontext.set(\"batteries_charging_last\", B_is_charging); //boolean\n\n// OUTFLOW\ncontext.set(\"time_last\", Date.now());\ncontext.set(\"I_integral_sum\", I_integral_sum);\n\n// OUTPUT\nRED.util.setMessageProperty(msg, \"batteries_charging\", B_is_charging, true);\nRED.util.setMessageProperty(msg, \"I_integral_sum\", I_integral_sum,true);\nmsg.payload = Number(PID_output);\n\n\nreturn [msg, // payload = PID_output\n { payload: Number(P1_error)},\n { payload: parseFloat(p_term)},\n { payload: parseFloat(i_term)},\n { payload: parseFloat(d_term)},\n { payload: B_is_charging},\n { payload: logdata }];","outputs":7,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":440,"y":700,"wires":[["71d8d3b4f6ef9310"],["ad085261cf8a2cc1"],["3db366538fbac912"],["9de6d5774b6b0bfa"],["f41cc8bd3b060241"],["817e304f8bb80d64"],["e0644847be635f58"]],"inputLabels":["battery_array"]},{"id":"e0644847be635f58","type":"debug","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Logdata","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"payload","targetType":"msg","statusVal":"","statusType":"auto","x":720,"y":1060,"wires":[]},{"id":"ad085261cf8a2cc1","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Error Signal","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_error_signal"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":730,"y":760,"wires":[["829124787188e159"]]},{"id":"2dc42d364f58d67f","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"PID Output","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_pid_output"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":730,"y":700,"wires":[["16582ddbc62d0305"]]},{"id":"7ede0bd5857ee7a0","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Proportional term","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_p_term"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":920,"y":820,"wires":[[]]},{"id":"5370c30d8cdab4b8","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Integral term","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_i_term"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":910,"y":880,"wires":[[]]},{"id":"ed568c00a0795773","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Differential term","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_d_term"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":920,"y":940,"wires":[[]]},{"id":"e05c810323db1ceb","type":"debug","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Charging","active":false,"tosidebar":false,"console":false,"tostatus":true,"complete":"payload","targetType":"msg","statusVal":"payload","statusType":"auto","x":900,"y":1060,"wires":[]},{"id":"16582ddbc62d0305","type":"smooth","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"","property":"payload","action":"sd","count":"8","round":"","mult":"single","reduce":false,"x":900,"y":700,"wires":[["303bb8cea00177f9"]]},{"id":"303bb8cea00177f9","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"PID deviation","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_pid_output_deviation"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":1070,"y":700,"wires":[[]]},{"id":"829124787188e159","type":"smooth","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"","property":"payload","action":"sd","count":"8","round":"","mult":"single","reduce":false,"x":900,"y":760,"wires":[["d4422f8539cc8ecf"]]},{"id":"d4422f8539cc8ecf","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Error deviation","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"input_number.set_value","floorId":[],"areaId":[],"deviceId":[],"entityId":["input_number.house_battery_control_error_deviation"],"labelId":[],"data":"{\"value\": \"{{payload}}\"}","dataType":"json","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":true,"domain":"input_number","service":"set_value","x":1080,"y":760,"wires":[[]]},{"id":"71d8d3b4f6ef9310","type":"function","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Output dampening","func":"// INPUT\nlet PID_unfiltered = msg.payload; // W Watt\nlet PID_last = context.get(\"PID_last\")||0; // W watt\nlet PID_damp = Number(flow.get(\"house_battery_control_pid_output_dampening\"))||0; // 0% - 100%\nPID_damp = PID_damp/100; // to Number\n\n// simple averaging filter\nlet PID_filtered = (1-PID_damp)*PID_unfiltered + (PID_damp)*PID_last;\n\n// OUTFLOW\ncontext.set(\"PID_last\", PID_filtered);\n\n// OUTPUT\nmsg.payload = Number(PID_filtered)\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":750,"y":660,"wires":[["4563c0e2adab2fee"]]},{"id":"4563c0e2adab2fee","type":"function","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Output failsafe","func":"// INFLOW\nlet maxCharge = msg.batteries_max_charge_power||undefined;\nlet maxDischarge = msg.batteries_max_discharge_power||undefined;\nlet isCharging = msg.batteries_charging||undefined;\nlet PID_output = msg.payload; // Watt\n\n// No failsafe\nif (maxCharge === undefined || maxDischarge == undefined) {\n node.status({ fill: \"red\", shape: \"dot\", text: \"disabled\" });\n node.warn(`Output Failsafe is INACTIVE. Max charge or discharge limits are undefined`);\n // continue without safeguard\n return msg;\n} \n\n// limit\nlet limit = 0;\n\n// charging state unknown\nif(isCharging === undefined) {\n // limit on lowest of charge / discharge\n limit = Math.min(maxCharge,maxDischarge); \n}\n// charging state known\nlimit = isCharging ? maxCharge : maxDischarge;\n\n// Safe output\nif (PID_output \u003c= limit){\n node.status({ fill: \"green\", shape: \"dot\", text: \"safe\" });\n return msg;\n} \n\n// Unsafe, limit output\nnode.status({ fill: \"yellow\", shape: \"ring\", text: \"power limiting\" });\nmsg.payload = Number(limit);\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":1080,"y":660,"wires":[["3acf8bf116f857e1","2dc42d364f58d67f"]]},{"id":"9de6d5774b6b0bfa","type":"rbe","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"on change","func":"rbe","gap":"","start":"","inout":"out","septopics":false,"property":"payload","topi":"topic","x":730,"y":880,"wires":[["5370c30d8cdab4b8"]]},{"id":"3acf8bf116f857e1","type":"function","z":"2dc255585c9021ad","g":"81378689693e940b","name":"Load distribution","func":"// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = msg.batteries; // Battery configuration as provided by `Home Batteries IO` flow\nvar isCharging = msg.batteries_charging; // Are we in a battery charging scenario?\nvar hasChargingLimiter = flow.get(\"has_soc_charging_limiter\") || false; // Battery life improvement\nvar hasReverseDischargePriority = flow.get(\"has_reverse_priority_discharge\") || false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\n\n// how much power do the batteries need to compensate?\nvar unassigned_power = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power} W`);\n\n// inits \nvar solution_array = []; // load distribution solutions\nvar batteries_total_assignable_power = 0; // Current max. available total (dis)charge power. Exceeding this max should trigger the anti-windup routine for the Integral terms\n\n// enums\nconst CMODE = {\n STOP: \"stop\", // Marstek batteries disconnect a relay when this is set or Power is 0W\n CHARGE: \"charge\",\n DISCHARGE: \"discharge\"\n}\n\n/**\n** Battery life improvement (slow charge near max SoC)\n* @param {number} soc\n* @param {number} max_power\n*/\nfunction chargingLimiter(soc, max_power) {\n // Must be greater than zero\n let limitedPower = Math.max(0, max_power); \n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc \u003e= 98) limitedPower = Math.min(300, limitedPower);\n else if (soc \u003e= 95) limitedPower = Math.min(500, limitedPower);\n else if (soc \u003e= 85) limitedPower = Math.min(1500, limitedPower);\n }\n return limitedPower;\n}\n\n/**\n* battery life improvement (disconnects battery only if the battery is not used for ... seconds)\n* @param {string | number} batteryID\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getStopSolution(batteryID) {\n // idle time, before stop is given to inverter relay.\n let time_idle_minimum = flow.get(\"idle_time_minutes\")*60*1000||0; // Milliseconds\n\n // retrieve last registered active times for each battery based on their ids\n let lasttime_active = context.get(\"lasttime_active\");\n \n // battery exists in register \n if(lasttime_active[batteryID] !== undefined){\n // timing\n let time_last = lasttime_active[batteryID]; // Milliseconds \n let time_now = Date.now(); // Milliseconds \n let time_idle = (time_now - time_last); // Milliseconds\n \n // battery is ready for STOP\n if (time_idle \u003e= time_idle_minimum) {\n log(this,`Battery ${batteryID} [STOP]: 0 Watt and disconnect relay.`);\n return {id: batteryID, mode: CMODE.STOP, power:0}; // STOP or 0 Watt disconnects relay\n }\n \n // battery is kept IDLE, while awaiting minimum inactive time\n log(this,`Battery ${batteryID} [IDLE]: 1 Watt for ${Math.round((time_idle_minimum-time_idle)/1000)}s. Keep relay engaged.`);\n return { id: batteryID, mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE, power: 1 }; // 1 Watt keeps battery IDLE, keeping relay engaged\n }\n\n // battery missing in register\n log(this,`Battery ${batteryID} [IDLE]: unknown last active time`);\n return getActiveSolution(batteryID, 1); // set a last active time and engage idle state\n}\n\n/**\n* Get battery solution and register a last active time.\n* @param {any} batteryID\n* @param {number} assigned_power\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getActiveSolution(batteryID, assigned_power){\n // register battery as active\n let lasttime_active = context.get(\"lasttime_active\")||[]; // last registered active times for each battery based on their ids\n lasttime_active[batteryID] = Date.now();\n context.set(\"lasttime_active\", lasttime_active);\n\n // solution\n return {\n id: batteryID,\n mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE,\n power: Math.round(assigned_power)\n };\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n batteries.reverse();\n}\n\n// -- LOAD BALANCER --\nbatteries.forEach((/** @type {{ id: any; soc: number; soc_min: number; soc_max: number; rs485: string; charging_max: number; discharging_max: any; }} */ battery) =\u003e {\n \n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; RS485: ${battery.rs485==\u0027enable\u0027?\u0027OK\u0027:\u0027Nok\u0027}`);\n\n // track if the battery should be skipped and why\n let skipReason = null;\n if (battery.rs485 !== \"enable\") {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because RS485 is not \u0027enable\u0027`;\n } else if (isCharging \u0026\u0026 battery.soc \u003e= battery.soc_max) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) \u003e= Max (${battery.soc_max}%) while charging`;\n } else if (!isCharging \u0026\u0026 battery.soc \u003c= battery.soc_min) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) \u003c= Min (${battery.soc_min}%) while discharging`;\n } else if (!isCharging \u0026\u0026 isDischargeDisabled) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n\n // battery NOT AVAIABLE, skip via soft stop\n if (skipReason !== null) {\n log(this, skipReason); // communicate reason\n let solution = getStopSolution(battery.id); // get a soft stop solution for this battery\n solution_array.push(solution); // add solution, do not update last active time.\n // next battery\n return; \n }\n\n // battery is AVAILABLE, assign power\n let battery_assignable_power = isCharging ? chargingLimiter(battery.soc, battery.charging_max) : battery.discharging_max;\n let assign = Math.min(unassigned_power, battery_assignable_power);\n \n // select solution\n var solution;\n if (assign \u003c= 0 ) {\n // no power solution\n solution = getStopSolution(battery.id); // a soft stop solution\n assign = 0;\n } else {\n // assigned power solution\n solution = getActiveSolution(battery.id, Math.round(assign));\n }\n solution_array.push(solution);\n \n // Explain: charge limiting\n if(unassigned_power \u003e battery_assignable_power \u0026\u0026 battery_assignable_power \u003c battery.charging_max) {\n log(this,`Charging limited to protect battery (${battery.soc}%): ${battery.charging_max}W -\u003e ${battery_assignable_power}W`);\n }\n // Explain: solution result\n log(this, `\u003cfont color=\"#009ac7\"\u003eBattery ${solution.id}\u003c/font\u003e: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? battery.charging_max : battery.discharging_max }W max.`)\n\n // remaining power to assign\n unassigned_power -= assign;\n batteries_total_assignable_power += Number(battery_assignable_power);\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n solution_array.reverse();\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n\n// OUTFLOW\nflow.set(\"batteries_total_assignable_power\", batteries_total_assignable_power); // this is set for the anti-windup calculation in the earlier \u0027calculate corrections\u0027 node, which will pick this up during next iteration.\n\n// OUTPUT\nreturn msg; // to Home Battery flow","outputs":1,"timeout":0,"noerr":0,"initialize":"// Code added here will be run once\n// whenever the node is started.\n\ncontext.set(\"lasttime_active\",[]);","finalize":"","libs":[],"x":420,"y":1160,"wires":[["9197f431cd22f55c","188b6d74052d7b75"]],"inputLabels":["isCharging (boolean)"]},{"id":"9197f431cd22f55c","type":"debug","z":"2dc255585c9021ad","g":"81378689693e940b","name":"Load distribution","active":false,"tosidebar":true,"console":false,"tostatus":false,"complete":"solutions","targetType":"msg","statusVal":"","statusType":"auto","x":640,"y":1160,"wires":[]},{"id":"deae32c2aaf84154","type":"switch","z":"2dc255585c9021ad","g":"085064404e672245","name":"Pass or Halt","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"halted by deadband","vt":"str"},{"t":"neq","v":"halted by deadband","vt":"str"}],"checkall":"false","repair":false,"outputs":2,"x":910,"y":340,"wires":[["2f97f335f207820a"],["72ac88c55f926b58"]]},{"id":"188b6d74052d7b75","type":"link out","z":"2dc255585c9021ad","g":"481e254058a8227d","name":"Return","mode":"return","links":[],"x":225,"y":1240,"wires":[]},{"id":"d6ba85caf21c6c7a","type":"comment","z":"2dc255585c9021ad","g":"481e254058a8227d","name":"End (readme)","info":"Should return a solution_array of battery objects\n\n## battery object format\n`{{id: string|number, mode: string, power: number}} battery solution`\n- id is an arbitrary battery ID\n- mode is \"stop\", \"charge\", \"discharge\" for Marstek\n- power in Watts\n\n### example array\nreturn this type of solution_array via msg.solutions\n` \nlet solution_array = [];\nsolution_array.push({id:\"M1\", mode: \"charge\", power: 100}); // per battery\nreturn {solutions: solution_array};\n` ","x":170,"y":1180,"wires":[]},{"id":"2f97f335f207820a","type":"link out","z":"2dc255585c9021ad","g":"085064404e672245","name":"go to End","mode":"link","links":["02843f3df46ffe5b"],"x":1090,"y":340,"wires":[],"inputLabels":["Halt"],"l":true},{"id":"02843f3df46ffe5b","type":"link in","z":"2dc255585c9021ad","g":"481e254058a8227d","name":"link to End","links":["2f97f335f207820a"],"x":105,"y":1240,"wires":[["188b6d74052d7b75"]]},{"id":"729b998cf3b87cc7","type":"api-call-service","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Is charging","server":"176d29a.6f648d6","version":7,"debugenabled":false,"action":"","floorId":[],"areaId":[],"deviceId":[],"entityId":[],"labelId":[],"data":"","dataType":"jsonata","mergeContext":"","mustacheAltTags":false,"outputProperties":[],"queue":"none","blockInputOverrides":false,"domain":"","service":"","x":1070,"y":1000,"wires":[[]]},{"id":"83a1a6be0e294782","type":"function","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"Config action","func":"const value = msg.payload;\nconst target = \"input_boolean.house_battery_control_is_charging\";\n\nmsg.payload = {\n \"action\": value?\"input_boolean.turn_on\":\"input_boolean.turn_off\",\n \"target\": {\n \"entity_id\": [target]\n },\n \"data\": {}\n}\n\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":910,"y":1000,"wires":[["729b998cf3b87cc7"]]},{"id":"c7386c2b9a7d1cc0","type":"switch","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"Target","property":"house_target_grid_consumption_in_w","propertyType":"msg","rules":[{"t":"nnull"},{"t":"else"}],"checkall":"false","repair":false,"outputs":2,"x":390,"y":120,"wires":[["edbd6dfed6ba7652"],["89c318c6c8728833"]]},{"id":"8bc9658f77284e22","type":"debug","z":"2dc255585c9021ad","g":"d4bd6010b2a82002","name":"Target","active":true,"tosidebar":false,"console":false,"tostatus":true,"complete":"house_target_grid_consumption_in_w","targetType":"msg","statusVal":"house_target_grid_consumption_in_w","statusType":"auto","x":790,"y":100,"wires":[]},{"id":"e2f0589bab3add2b","type":"server-state-changed","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Strategy changes","server":"176d29a.6f648d6","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["input_text.house_battery_strategy_active_sub_strategy","input_select.house_battery_strategy"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"string","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"x":1720,"y":340,"wires":[["85a1019efd05b4bd"]]},{"id":"85a1019efd05b4bd","type":"switch","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"Full stop","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":1855,"y":340,"wires":[["2a5a379c33bdc594"]],"l":false},{"id":"bc18c2754c562a1a","type":"server-state-changed","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"Full stop trigger","server":"176d29a.6f648d6","version":6,"outputs":1,"exposeAsEntityConfig":"","entities":{"entity":["sensor.ev_is_charging"],"substring":[],"regex":[]},"outputInitially":false,"stateType":"str","ifState":"","ifStateType":"str","ifStateOperator":"is","outputOnlyOnStateChange":true,"for":"0","forType":"num","forUnits":"minutes","ignorePrevStateNull":false,"ignorePrevStateUnknown":false,"ignorePrevStateUnavailable":false,"ignoreCurrentStateUnknown":false,"ignoreCurrentStateUnavailable":false,"outputProperties":[{"property":"payload","propertyType":"msg","value":"string","valueType":"entityState"},{"property":"data","propertyType":"msg","value":"","valueType":"eventData"},{"property":"topic","propertyType":"msg","value":"","valueType":"triggerId"}],"x":1720,"y":420,"wires":[["332d68169bd129ff"]]},{"id":"332d68169bd129ff","type":"switch","z":"2dc255585c9021ad","g":"fa53931ece8d9e5c","name":"","property":"payload","propertyType":"msg","rules":[{"t":"eq","v":"on","vt":"str"}],"checkall":"true","repair":false,"outputs":1,"x":1855,"y":420,"wires":[["2a5a379c33bdc594"]],"l":false},{"id":"3db366538fbac912","type":"rbe","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"on change","func":"rbe","gap":"","start":"","inout":"out","septopics":false,"property":"payload","topi":"topic","x":730,"y":820,"wires":[["7ede0bd5857ee7a0"]]},{"id":"f41cc8bd3b060241","type":"rbe","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"on change","func":"rbe","gap":"","start":"","inout":"out","septopics":false,"property":"payload","topi":"topic","x":730,"y":940,"wires":[["ed568c00a0795773"]]},{"id":"817e304f8bb80d64","type":"rbe","z":"2dc255585c9021ad","g":"0538d5e767988695","name":"on change","func":"rbe","gap":"","start":"","inout":"out","septopics":false,"property":"payload","topi":"topic","x":730,"y":1000,"wires":[["83a1a6be0e294782","e05c810323db1ceb"]]},{"id":"6333a8d8470cd4f5","type":"change","z":"2dc255585c9021ad","g":"ba3cab11cfa58369","name":"Trace","rules":[{"t":"set","p":"strategy.trace","pt":"msg","to":"[\t strategy.trace,\t {\t \"flow\": target,\t \"flow_settings\": advanced_settings,\t \"timestamp\": $now()\t } \t]","tot":"jsonata"}],"action":"","property":"","from":"","to":"","reg":false,"x":190,"y":200,"wires":[["c7386c2b9a7d1cc0"]],"info":"Attaches a breadcrumb trace to the msg\r\nso the user can reconstruct which route has\r\nbeen traveled"},{"id":"916c17ae8b44a945","type":"catch","z":"2dc255585c9021ad","g":"ff7db384260f1b22","name":"","scope":null,"uncaught":false,"x":95,"y":320,"wires":[["768fa0dcf7e8e3b5"]],"l":false},{"id":"768fa0dcf7e8e3b5","type":"function","z":"2dc255585c9021ad","g":"ff7db384260f1b22","name":"Unhandled Exception","func":"const handle = global.get(\u0027unhandledException\u0027);\n\nhandle(this);\n\nreturn msg;","outputs":1,"timeout":0,"noerr":0,"initialize":"","finalize":"","libs":[],"x":145,"y":320,"wires":[["30ea27bc433988a5"]],"l":false},{"id":"30ea27bc433988a5","type":"link out","z":"2dc255585c9021ad","g":"ff7db384260f1b22","name":"link out 7","mode":"return","links":[],"x":195,"y":320,"wires":[]},{"id":"176d29a.6f648d6","type":"server","name":"Home Assistant","addon":true,"rejectUnauthorizedCerts":true,"ha_boolean":"","connectionDelay":false,"cacheJson":false,"heartbeat":false,"heartbeatInterval":"","statusSeparator":"","enableGlobalContextStore":false},{"id":"4daf6e7bcce195d0","type":"global-config","env":[],"modules":{"node-red-contrib-home-assistant-websocket":"0.80.3","node-red-node-smooth":"0.1.2"}}]
From 8688d6eeb967bc2049519e034cf1e2c8c8c24404 Mon Sep 17 00:00:00 2001
From: sammyjo468 <127890142+sammyjo468@users.noreply.github.com>
Date: Wed, 4 Feb 2026 09:58:11 +0100
Subject: [PATCH 2/6] Improve setup instructions for self-consumption
Clarified instructions for determining resonant frequency and exporting data.
---
docs/04-setup-self-consumption.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/04-setup-self-consumption.md b/docs/04-setup-self-consumption.md
index 922249f..65e051c 100644
--- a/docs/04-setup-self-consumption.md
+++ b/docs/04-setup-self-consumption.md
@@ -56,7 +56,7 @@ Use the [Ziegler-Nichols method](https://en.wikipedia.org/wiki/Proportional%E2%8
- increase Kp when not resonating
- decrease Kp if the system runs off. (stay alert, not to damage your system)
1. Determine the resonant frequency. E.g. by using [HA-history-graph-csv-export-analysis
-](https://github.com/gitcodebob/HA-history-graph-csv-export-analysis)
+](https://github.com/gitcodebob/HA-history-graph-csv-export-analysis). You need to download these CVS data first from the History dashboard, P1 Meter Power sensor. Only use a period there the battery management has actually been able to minimize P1 (i.e., not full stop, charge only, empty or full battery etc).
1. Tu = 1 / `` and Ku = your current Kp during resonance
1. Use the table of the [Ziegler-Nichols method](https://en.wikipedia.org/wiki/Proportional%E2%80%93integral%E2%80%93derivative_controller#Ziegler%E2%80%93Nichols_method) to get a baseline.
- This baseline can be a bit aggressive.
From 1ba51053e40f5e82ac26c15e789b378f657a336b Mon Sep 17 00:00:00 2001
From: sammyjo468 <127890142+sammyjo468@users.noreply.github.com>
Date: Fri, 6 Feb 2026 22:46:31 +0100
Subject: [PATCH 3/6] Refactor load distribution function in JSON file
min SoC defined by Marstek is 12.0%. Due to premature rounding, the cutoff is already at 12.9%. This is a suggestion to take into account unrounded numbers.
---
node-red/all-flows-in-one-file.json | 4 ++--
1 file changed, 2 insertions(+), 2 deletions(-)
diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json
index 4bc6ea0..be0698d 100644
--- a/node-red/all-flows-in-one-file.json
+++ b/node-red/all-flows-in-one-file.json
@@ -9870,7 +9870,7 @@
"z": "2ebbb6bcc8c1e75c",
"g": "31a6251d574684ca",
"name": "Load distribution",
- "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = msg.batteries; // Battery configuration as provided by `Home Batteries IO` flow\nvar isCharging = msg.batteries_charging; // Are we in a battery charging scenario?\nvar hasChargingLimiter = flow.get(\"has_soc_charging_limiter\") || false; // Battery life improvement\nvar hasReverseDischargePriority = flow.get(\"has_reverse_priority_discharge\") || false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\n\n// how much power do the batteries need to compensate?\nvar unassigned_power = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power} W`);\n\n// inits \nvar solution_array = []; // load distribution solutions\nvar batteries_total_assignable_power = 0; // Current max. available total (dis)charge power. Exceeding this max should trigger the anti-windup routine for the Integral terms\n\n// enums\nconst CMODE = {\n STOP: \"stop\", // Marstek batteries disconnect a relay when this is set or Power is 0W\n CHARGE: \"charge\",\n DISCHARGE: \"discharge\"\n}\n\n/**\n** Battery life improvement (slow charge near max SoC)\n* @param {number} soc\n* @param {number} max_power\n*/\nfunction chargingLimiter(soc, max_power) {\n // Must be greater than zero\n let limitedPower = Math.max(0, max_power); \n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc \u003e= 98) limitedPower = Math.min(300, limitedPower);\n else if (soc \u003e= 95) limitedPower = Math.min(500, limitedPower);\n else if (soc \u003e= 85) limitedPower = Math.min(1500, limitedPower);\n }\n return limitedPower;\n}\n\n/**\n* battery life improvement (disconnects battery only if the battery is not used for ... seconds)\n* @param {string | number} batteryID\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getStopSolution(batteryID) {\n // idle time, before stop is given to inverter relay.\n let time_idle_minimum = flow.get(\"idle_time_minutes\")*60*1000||0; // Milliseconds\n\n // retrieve last registered active times for each battery based on their ids\n let lasttime_active = context.get(\"lasttime_active\");\n \n // battery exists in register \n if(lasttime_active[batteryID] !== undefined){\n // timing\n let time_last = lasttime_active[batteryID]; // Milliseconds \n let time_now = Date.now(); // Milliseconds \n let time_idle = (time_now - time_last); // Milliseconds\n \n // battery is ready for STOP\n if (time_idle \u003e= time_idle_minimum) {\n log(this,`Battery ${batteryID} [STOP]: 0 Watt and disconnect relay.`);\n return {id: batteryID, mode: CMODE.STOP, power:0}; // STOP or 0 Watt disconnects relay\n }\n \n // battery is kept IDLE, while awaiting minimum inactive time\n log(this,`Battery ${batteryID} [IDLE]: 1 Watt for ${Math.round((time_idle_minimum-time_idle)/1000)}s. Keep relay engaged.`);\n return { id: batteryID, mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE, power: 1 }; // 1 Watt keeps battery IDLE, keeping relay engaged\n }\n\n // battery missing in register\n log(this,`Battery ${batteryID} [IDLE]: unknown last active time`);\n return getActiveSolution(batteryID, 1); // set a last active time and engage idle state\n}\n\n/**\n* Get battery solution and register a last active time.\n* @param {any} batteryID\n* @param {number} assigned_power\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getActiveSolution(batteryID, assigned_power){\n // register battery as active\n let lasttime_active = context.get(\"lasttime_active\")||[]; // last registered active times for each battery based on their ids\n lasttime_active[batteryID] = Date.now();\n context.set(\"lasttime_active\", lasttime_active);\n\n // solution\n return {\n id: batteryID,\n mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE,\n power: Math.round(assigned_power)\n };\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n batteries.reverse();\n}\n\n// -- LOAD BALANCER --\nbatteries.forEach((/** @type {{ id: any; soc: number; soc_min: number; soc_max: number; rs485: string; charging_max: number; discharging_max: any; }} */ battery) =\u003e {\n \n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; RS485: ${battery.rs485==\u0027enable\u0027?\u0027OK\u0027:\u0027Nok\u0027}`);\n\n // track if the battery should be skipped and why\n let skipReason = null;\n if (battery.rs485 !== \"enable\") {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because RS485 is not \u0027enable\u0027`;\n } else if (isCharging \u0026\u0026 battery.soc \u003e= battery.soc_max) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) \u003e= Max (${battery.soc_max}%) while charging`;\n } else if (!isCharging \u0026\u0026 battery.soc \u003c= battery.soc_min) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) \u003c= Min (${battery.soc_min}%) while discharging`;\n } else if (!isCharging \u0026\u0026 isDischargeDisabled) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n\n // battery NOT AVAIABLE, skip via soft stop\n if (skipReason !== null) {\n log(this, skipReason); // communicate reason\n let solution = getStopSolution(battery.id); // get a soft stop solution for this battery\n solution_array.push(solution); // add solution, do not update last active time.\n // next battery\n return; \n }\n\n // battery is AVAILABLE, assign power\n let battery_assignable_power = isCharging ? chargingLimiter(battery.soc, battery.charging_max) : battery.discharging_max;\n let assign = Math.min(unassigned_power, battery_assignable_power);\n \n // select solution\n var solution;\n if (assign \u003c= 0 ) {\n // no power solution\n solution = getStopSolution(battery.id); // a soft stop solution\n assign = 0;\n } else {\n // assigned power solution\n solution = getActiveSolution(battery.id, Math.round(assign));\n }\n solution_array.push(solution);\n \n // Explain: charge limiting\n if(unassigned_power \u003e battery_assignable_power \u0026\u0026 battery_assignable_power \u003c battery.charging_max) {\n log(this,`Charging limited to protect battery (${battery.soc}%): ${battery.charging_max}W -\u003e ${battery_assignable_power}W`);\n }\n // Explain: solution result\n log(this, `\u003cfont color=\"#009ac7\"\u003eBattery ${solution.id}\u003c/font\u003e: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? battery.charging_max : battery.discharging_max }W max.`)\n\n // remaining power to assign\n unassigned_power -= assign;\n batteries_total_assignable_power += Number(battery_assignable_power);\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n solution_array.reverse();\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n\n// OUTFLOW\nflow.set(\"batteries_total_assignable_power\", batteries_total_assignable_power); // this is set for the anti-windup calculation in the earlier \u0027calculate corrections\u0027 node, which will pick this up during next iteration.\n\n// OUTPUT\nreturn msg; // to Home Battery flow",
+ "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = msg.batteries; // Battery configuration as provided by `Home Batteries IO` flow\nvar isCharging = msg.batteries_charging; // Are we in a battery charging scenario?\nvar hasChargingLimiter = flow.get(\"has_soc_charging_limiter\") || false; // Battery life improvement\nvar hasReverseDischargePriority = flow.get(\"has_reverse_priority_discharge\") || false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\n\n// how much power do the batteries need to compensate?\nvar unassigned_power = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power} W`);\n\n// inits \nvar solution_array = []; // load distribution solutions\nvar batteries_total_assignable_power = 0; // Current max. available total (dis)charge power. Exceeding this max should trigger the anti-windup routine for the Integral terms\n\n// enums\nconst CMODE = {\n STOP: \"stop\", // Marstek batteries disconnect a relay when this is set or Power is 0W\n CHARGE: \"charge\",\n DISCHARGE: \"discharge\"\n}\n\n/**\n** Battery life improvement (slow charge near max SoC)\n* @param {number} soc\n* @param {number} max_power\n*/\nfunction chargingLimiter(soc, max_power) {\n // Must be greater than zero\n let limitedPower = Math.max(0, max_power); \n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc \u003e= 98) limitedPower = Math.min(300, limitedPower);\n else if (soc \u003e= 95) limitedPower = Math.min(500, limitedPower);\n else if (soc \u003e= 85) limitedPower = Math.min(1500, limitedPower);\n }\n return limitedPower;\n}\n\n/**\n* battery life improvement (disconnects battery only if the battery is not used for ... seconds)\n* @param {string | number} batteryID\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getStopSolution(batteryID) {\n // idle time, before stop is given to inverter relay.\n let time_idle_minimum = flow.get(\"idle_time_minutes\")*60*1000||0; // Milliseconds\n\n // retrieve last registered active times for each battery based on their ids\n let lasttime_active = context.get(\"lasttime_active\");\n \n // battery exists in register \n if(lasttime_active[batteryID] !== undefined){\n // timing\n let time_last = lasttime_active[batteryID]; // Milliseconds \n let time_now = Date.now(); // Milliseconds \n let time_idle = (time_now - time_last); // Milliseconds\n \n // battery is ready for STOP\n if (time_idle \u003e= time_idle_minimum) {\n log(this,`Battery ${batteryID} [STOP]: 0 Watt and disconnect relay.`);\n return {id: batteryID, mode: CMODE.STOP, power:0}; // STOP or 0 Watt disconnects relay\n }\n \n // battery is kept IDLE, while awaiting minimum inactive time\n log(this,`Battery ${batteryID} [IDLE]: 1 Watt for ${Math.round((time_idle_minimum-time_idle)/1000)}s. Keep relay engaged.`);\n return { id: batteryID, mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE, power: 1 }; // 1 Watt keeps battery IDLE, keeping relay engaged\n }\n\n // battery missing in register\n log(this,`Battery ${batteryID} [IDLE]: unknown last active time`);\n return getActiveSolution(batteryID, 1); // set a last active time and engage idle state\n}\n\n/**\n* Get battery solution and register a last active time.\n* @param {any} batteryID\n* @param {number} assigned_power\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getActiveSolution(batteryID, assigned_power){\n // register battery as active\n let lasttime_active = context.get(\"lasttime_active\")||[]; // last registered active times for each battery based on their ids\n lasttime_active[batteryID] = Date.now();\n context.set(\"lasttime_active\", lasttime_active);\n\n // solution\n return {\n id: batteryID,\n mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE,\n power: Math.round(assigned_power)\n };\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n batteries.reverse();\n}\n\n// -- LOAD BALANCER --\nbatteries.forEach((/** @type {{ id: any; soc: number; soc_min: number; soc_max: number; rs485: string; charging_max: number; discharging_max: any; }} */ battery) =\u003e {\n \n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; RS485: ${battery.rs485==\u0027enable\u0027?\u0027OK\u0027:\u0027Nok\u0027}`);\n\n // track if the battery should be skipped and why\n let skipReason = null;\n if (battery.rs485 !== \"enable\") {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because RS485 is not \u0027enable\u0027`;\n } else if (isCharging \u0026\u0026 battery.soc \u003e= battery.soc_max) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) \u003e= Max (${battery.soc_max}%) while charging`;\n const soc = Number(battery.soc);\n const soc_min = Number(battery.soc_min);\n } else if (!isCharging && soc < soc_min) { skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${soc.toFixed(1)}%) < Min (${soc_min.toFixed(1)}%) while discharging`; }\n } else if (!isCharging \u0026\u0026 isDischargeDisabled) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n\n // battery NOT AVAIABLE, skip via soft stop\n if (skipReason !== null) {\n log(this, skipReason); // communicate reason\n let solution = getStopSolution(battery.id); // get a soft stop solution for this battery\n solution_array.push(solution); // add solution, do not update last active time.\n // next battery\n return; \n }\n\n // battery is AVAILABLE, assign power\n let battery_assignable_power = isCharging ? chargingLimiter(battery.soc, battery.charging_max) : battery.discharging_max;\n let assign = Math.min(unassigned_power, battery_assignable_power);\n \n // select solution\n var solution;\n if (assign \u003c= 0 ) {\n // no power solution\n solution = getStopSolution(battery.id); // a soft stop solution\n assign = 0;\n } else {\n // assigned power solution\n solution = getActiveSolution(battery.id, Math.round(assign));\n }\n solution_array.push(solution);\n \n // Explain: charge limiting\n if(unassigned_power \u003e battery_assignable_power \u0026\u0026 battery_assignable_power \u003c battery.charging_max) {\n log(this,`Charging limited to protect battery (${battery.soc}%): ${battery.charging_max}W -\u003e ${battery_assignable_power}W`);\n }\n // Explain: solution result\n log(this, `\u003cfont color=\"#009ac7\"\u003eBattery ${solution.id}\u003c/font\u003e: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? battery.charging_max : battery.discharging_max }W max.`)\n\n // remaining power to assign\n unassigned_power -= assign;\n batteries_total_assignable_power += Number(battery_assignable_power);\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n solution_array.reverse();\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n\n// OUTFLOW\nflow.set(\"batteries_total_assignable_power\", batteries_total_assignable_power); // this is set for the anti-windup calculation in the earlier \u0027calculate corrections\u0027 node, which will pick this up during next iteration.\n\n// OUTPUT\nreturn msg; // to Home Battery flow",
"outputs": 1,
"timeout": 0,
"noerr": 0,
@@ -12730,4 +12730,4 @@
"node-red-contrib-time-range-switch": "1.2.0"
}
}
-]
\ No newline at end of file
+]
From 393ecba50a4b3ff5a56a91c9fb60d0479b5f27e0 Mon Sep 17 00:00:00 2001
From: sammyjo468 <127890142+sammyjo468@users.noreply.github.com>
Date: Fri, 6 Feb 2026 22:52:22 +0100
Subject: [PATCH 4/6] Fix formatting issues in setup self-consumption guide
as requested
---
docs/04-setup-self-consumption.md | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/docs/04-setup-self-consumption.md b/docs/04-setup-self-consumption.md
index 65e051c..c609459 100644
--- a/docs/04-setup-self-consumption.md
+++ b/docs/04-setup-self-consumption.md
@@ -56,7 +56,7 @@ Use the [Ziegler-Nichols method](https://en.wikipedia.org/wiki/Proportional%E2%8
- increase Kp when not resonating
- decrease Kp if the system runs off. (stay alert, not to damage your system)
1. Determine the resonant frequency. E.g. by using [HA-history-graph-csv-export-analysis
-](https://github.com/gitcodebob/HA-history-graph-csv-export-analysis). You need to download these CVS data first from the History dashboard, P1 Meter Power sensor. Only use a period there the battery management has actually been able to minimize P1 (i.e., not full stop, charge only, empty or full battery etc).
+](https://github.com/gitcodebob/HA-history-graph-csv-export-analysis). You need to download these CSV data first from the History dashboard, P1 Meter Power sensor. Only use a period there the battery management has actually been able to minimize P1 (i.e., not full stop, charge only, empty or full battery etc).
1. Tu = 1 / `` and Ku = your current Kp during resonance
1. Use the table of the [Ziegler-Nichols method](https://en.wikipedia.org/wiki/Proportional%E2%80%93integral%E2%80%93derivative_controller#Ziegler%E2%80%93Nichols_method) to get a baseline.
- This baseline can be a bit aggressive.
From 6962e9e53fd578c43b7bdca06d006988ddc6e77f Mon Sep 17 00:00:00 2001
From: sammyjo468 <127890142+sammyjo468@users.noreply.github.com>
Date: Tue, 10 Feb 2026 20:35:03 +0100
Subject: [PATCH 5/6] Update house battery control config with recorder
settings (#1)
Added recorder configuration to exclude specific entities.
---
home assistant/packages/house_battery_control_config.yaml | 6 +++++-
1 file changed, 5 insertions(+), 1 deletion(-)
diff --git a/home assistant/packages/house_battery_control_config.yaml b/home assistant/packages/house_battery_control_config.yaml
index 7572993..403a3d6 100644
--- a/home assistant/packages/house_battery_control_config.yaml
+++ b/home assistant/packages/house_battery_control_config.yaml
@@ -2,6 +2,11 @@
# It should be included in the main configuration.yaml file as a `package` via `!include_dir_named` https://www.home-assistant.io/docs/configuration/packages/.
# Use the values below as a `template` for your specific needs
+recorder:
+ exclude:
+ entities:
+ - sensor.l1_meter_power
+
template:
- sensor:
# Example: sensor for single-phase power meter (split into consumption and production)
@@ -9,7 +14,6 @@ template:
unique_id: "l1_meter_power"
unit_of_measurement: "W"
device_class: "power"
- state_class: "measurement"
state: >
{% set consumption = states('sensor.electricity_meter_power_consumption_phase_l1') | float(0) %}
{% set production = states('sensor.electricity_meter_power_production_phase_l1') | float(0) %}
From 30335fb3831902b172c4df24f814d8bdccc0a116 Mon Sep 17 00:00:00 2001
From: sammyjo468 <127890142+sammyjo468@users.noreply.github.com>
Date: Wed, 11 Feb 2026 00:02:29 +0100
Subject: [PATCH 6/6] Refactor load distribution function in JSON file
---
node-red/all-flows-in-one-file.json | 2 +-
1 file changed, 1 insertion(+), 1 deletion(-)
diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json
index be0698d..7b71f17 100644
--- a/node-red/all-flows-in-one-file.json
+++ b/node-red/all-flows-in-one-file.json
@@ -9870,7 +9870,7 @@
"z": "2ebbb6bcc8c1e75c",
"g": "31a6251d574684ca",
"name": "Load distribution",
- "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = msg.batteries; // Battery configuration as provided by `Home Batteries IO` flow\nvar isCharging = msg.batteries_charging; // Are we in a battery charging scenario?\nvar hasChargingLimiter = flow.get(\"has_soc_charging_limiter\") || false; // Battery life improvement\nvar hasReverseDischargePriority = flow.get(\"has_reverse_priority_discharge\") || false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\n\n// how much power do the batteries need to compensate?\nvar unassigned_power = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power} W`);\n\n// inits \nvar solution_array = []; // load distribution solutions\nvar batteries_total_assignable_power = 0; // Current max. available total (dis)charge power. Exceeding this max should trigger the anti-windup routine for the Integral terms\n\n// enums\nconst CMODE = {\n STOP: \"stop\", // Marstek batteries disconnect a relay when this is set or Power is 0W\n CHARGE: \"charge\",\n DISCHARGE: \"discharge\"\n}\n\n/**\n** Battery life improvement (slow charge near max SoC)\n* @param {number} soc\n* @param {number} max_power\n*/\nfunction chargingLimiter(soc, max_power) {\n // Must be greater than zero\n let limitedPower = Math.max(0, max_power); \n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc \u003e= 98) limitedPower = Math.min(300, limitedPower);\n else if (soc \u003e= 95) limitedPower = Math.min(500, limitedPower);\n else if (soc \u003e= 85) limitedPower = Math.min(1500, limitedPower);\n }\n return limitedPower;\n}\n\n/**\n* battery life improvement (disconnects battery only if the battery is not used for ... seconds)\n* @param {string | number} batteryID\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getStopSolution(batteryID) {\n // idle time, before stop is given to inverter relay.\n let time_idle_minimum = flow.get(\"idle_time_minutes\")*60*1000||0; // Milliseconds\n\n // retrieve last registered active times for each battery based on their ids\n let lasttime_active = context.get(\"lasttime_active\");\n \n // battery exists in register \n if(lasttime_active[batteryID] !== undefined){\n // timing\n let time_last = lasttime_active[batteryID]; // Milliseconds \n let time_now = Date.now(); // Milliseconds \n let time_idle = (time_now - time_last); // Milliseconds\n \n // battery is ready for STOP\n if (time_idle \u003e= time_idle_minimum) {\n log(this,`Battery ${batteryID} [STOP]: 0 Watt and disconnect relay.`);\n return {id: batteryID, mode: CMODE.STOP, power:0}; // STOP or 0 Watt disconnects relay\n }\n \n // battery is kept IDLE, while awaiting minimum inactive time\n log(this,`Battery ${batteryID} [IDLE]: 1 Watt for ${Math.round((time_idle_minimum-time_idle)/1000)}s. Keep relay engaged.`);\n return { id: batteryID, mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE, power: 1 }; // 1 Watt keeps battery IDLE, keeping relay engaged\n }\n\n // battery missing in register\n log(this,`Battery ${batteryID} [IDLE]: unknown last active time`);\n return getActiveSolution(batteryID, 1); // set a last active time and engage idle state\n}\n\n/**\n* Get battery solution and register a last active time.\n* @param {any} batteryID\n* @param {number} assigned_power\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getActiveSolution(batteryID, assigned_power){\n // register battery as active\n let lasttime_active = context.get(\"lasttime_active\")||[]; // last registered active times for each battery based on their ids\n lasttime_active[batteryID] = Date.now();\n context.set(\"lasttime_active\", lasttime_active);\n\n // solution\n return {\n id: batteryID,\n mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE,\n power: Math.round(assigned_power)\n };\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n batteries.reverse();\n}\n\n// -- LOAD BALANCER --\nbatteries.forEach((/** @type {{ id: any; soc: number; soc_min: number; soc_max: number; rs485: string; charging_max: number; discharging_max: any; }} */ battery) =\u003e {\n \n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; RS485: ${battery.rs485==\u0027enable\u0027?\u0027OK\u0027:\u0027Nok\u0027}`);\n\n // track if the battery should be skipped and why\n let skipReason = null;\n if (battery.rs485 !== \"enable\") {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because RS485 is not \u0027enable\u0027`;\n } else if (isCharging \u0026\u0026 battery.soc \u003e= battery.soc_max) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) \u003e= Max (${battery.soc_max}%) while charging`;\n const soc = Number(battery.soc);\n const soc_min = Number(battery.soc_min);\n } else if (!isCharging && soc < soc_min) { skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${soc.toFixed(1)}%) < Min (${soc_min.toFixed(1)}%) while discharging`; }\n } else if (!isCharging \u0026\u0026 isDischargeDisabled) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n\n // battery NOT AVAIABLE, skip via soft stop\n if (skipReason !== null) {\n log(this, skipReason); // communicate reason\n let solution = getStopSolution(battery.id); // get a soft stop solution for this battery\n solution_array.push(solution); // add solution, do not update last active time.\n // next battery\n return; \n }\n\n // battery is AVAILABLE, assign power\n let battery_assignable_power = isCharging ? chargingLimiter(battery.soc, battery.charging_max) : battery.discharging_max;\n let assign = Math.min(unassigned_power, battery_assignable_power);\n \n // select solution\n var solution;\n if (assign \u003c= 0 ) {\n // no power solution\n solution = getStopSolution(battery.id); // a soft stop solution\n assign = 0;\n } else {\n // assigned power solution\n solution = getActiveSolution(battery.id, Math.round(assign));\n }\n solution_array.push(solution);\n \n // Explain: charge limiting\n if(unassigned_power \u003e battery_assignable_power \u0026\u0026 battery_assignable_power \u003c battery.charging_max) {\n log(this,`Charging limited to protect battery (${battery.soc}%): ${battery.charging_max}W -\u003e ${battery_assignable_power}W`);\n }\n // Explain: solution result\n log(this, `\u003cfont color=\"#009ac7\"\u003eBattery ${solution.id}\u003c/font\u003e: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? battery.charging_max : battery.discharging_max }W max.`)\n\n // remaining power to assign\n unassigned_power -= assign;\n batteries_total_assignable_power += Number(battery_assignable_power);\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging \u0026\u0026 hasReverseDischargePriority) {\n solution_array.reverse();\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n\n// OUTFLOW\nflow.set(\"batteries_total_assignable_power\", batteries_total_assignable_power); // this is set for the anti-windup calculation in the earlier \u0027calculate corrections\u0027 node, which will pick this up during next iteration.\n\n// OUTPUT\nreturn msg; // to Home Battery flow",
+ "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = msg.batteries; // Battery configuration as provided by `Home Batteries IO` flow\nvar isCharging = msg.batteries_charging; // Are we in a battery charging scenario?\nvar hasChargingLimiter = flow.get(\"has_soc_charging_limiter\") || false; // Battery life improvement\nvar hasReverseDischargePriority = flow.get(\"has_reverse_priority_discharge\") || false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\n\n// how much power do the batteries need to compensate?\nvar unassigned_power = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power} W`);\n\n// inits \nvar solution_array = []; // load distribution solutions\nvar batteries_total_assignable_power = 0; // Current max. available total (dis)charge power. Exceeding this max should trigger the anti-windup routine for the Integral terms\n\n// enums\nconst CMODE = {\n STOP: \"stop\", // Marstek batteries disconnect a relay when this is set or Power is 0W\n CHARGE: \"charge\",\n DISCHARGE: \"discharge\"\n}\n\n/**\n** Battery life improvement (slow charge near max SoC)\n* @param {number} soc\n* @param {number} max_power\n*/\nfunction chargingLimiter(soc, max_power) {\n // Must be greater than zero\n let limitedPower = Math.max(0, max_power); \n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 98) limitedPower = Math.min(300, limitedPower);\n else if (soc >= 95) limitedPower = Math.min(500, limitedPower);\n else if (soc >= 85) limitedPower = Math.min(1500, limitedPower);\n }\n return limitedPower;\n}\n\n/**\n* battery life improvement (disconnects battery only if the battery is not used for ... seconds)\n* @param {string | number} batteryID\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getStopSolution(batteryID) {\n // idle time, before stop is given to inverter relay.\n let time_idle_minimum = flow.get(\"idle_time_minutes\")*60*1000||0; // Milliseconds\n\n // retrieve last registered active times for each battery based on their ids\n let lasttime_active = context.get(\"lasttime_active\");\n \n // battery exists in register \n if(lasttime_active[batteryID] !== undefined){\n // timing\n let time_last = lasttime_active[batteryID]; // Milliseconds \n let time_now = Date.now(); // Milliseconds \n let time_idle = (time_now - time_last); // Milliseconds\n \n // battery is ready for STOP\n if (time_idle >= time_idle_minimum) {\n log(this,`Battery ${batteryID} [STOP]: 0 Watt and disconnect relay.`);\n return {id: batteryID, mode: CMODE.STOP, power:0}; // STOP or 0 Watt disconnects relay\n }\n \n // battery is kept IDLE, while awaiting minimum inactive time\n log(this,`Battery ${batteryID} [IDLE]: 1 Watt for ${Math.round((time_idle_minimum-time_idle)/1000)}s. Keep relay engaged.`);\n return { id: batteryID, mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE, power: 1 }; // 1 Watt keeps battery IDLE, keeping relay engaged\n }\n\n // battery missing in register\n log(this,`Battery ${batteryID} [IDLE]: unknown last active time`);\n return getActiveSolution(batteryID, 1); // set a last active time and engage idle state\n}\n\n/**\n* Get battery solution and register a last active time.\n* @param {any} batteryID\n* @param {number} assigned_power\n* @returns {{id: string|number, mode: string, power: number}} battery solution\n*/\nfunction getActiveSolution(batteryID, assigned_power){\n // register battery as active\n let lasttime_active = context.get(\"lasttime_active\")||[]; // last registered active times for each battery based on their ids\n lasttime_active[batteryID] = Date.now();\n context.set(\"lasttime_active\", lasttime_active);\n\n // solution\n return {\n id: batteryID,\n mode: isCharging ? CMODE.CHARGE : CMODE.DISCHARGE,\n power: Math.round(assigned_power)\n };\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\n// -- LOAD BALANCER --\nbatteries.forEach((/** @type {{ id: any; soc: number; soc_min: number; soc_max: number; rs485: string; charging_max: number; discharging_max: any; }} */ battery) => {\n \n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n // track if the battery should be skipped and why\n let skipReason = null;\n if (battery.rs485 !== \"enable\") {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n } else if (isCharging && battery.soc >= battery.soc_max) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n } else if (!isCharging && battery.soc <= (battery.soc_min - 1)) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n } else if (!isCharging && isDischargeDisabled) {\n skipReason = `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n\n // battery NOT AVAIABLE, skip via soft stop\n if (skipReason !== null) {\n log(this, skipReason); // communicate reason\n let solution = getStopSolution(battery.id); // get a soft stop solution for this battery\n solution_array.push(solution); // add solution, do not update last active time.\n // next battery\n return; \n }\n\n // battery is AVAILABLE, assign power\n let battery_assignable_power = isCharging ? chargingLimiter(battery.soc, battery.charging_max) : battery.discharging_max;\n let assign = Math.min(unassigned_power, battery_assignable_power);\n \n // select solution\n var solution;\n if (assign <= 0 ) {\n // no power solution\n solution = getStopSolution(battery.id); // a soft stop solution\n assign = 0;\n } else {\n // assigned power solution\n solution = getActiveSolution(battery.id, Math.round(assign));\n }\n solution_array.push(solution);\n \n // Explain: charge limiting\n if(unassigned_power > battery_assignable_power && battery_assignable_power < battery.charging_max) {\n log(this,`Charging limited to protect battery (${battery.soc}%): ${battery.charging_max}W -> ${battery_assignable_power}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? battery.charging_max : battery.discharging_max }W max.`)\n\n // remaining power to assign\n unassigned_power -= assign;\n batteries_total_assignable_power += Number(battery_assignable_power);\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n\n// OUTFLOW\nflow.set(\"batteries_total_assignable_power\", batteries_total_assignable_power); // this is set for the anti-windup calculation in the earlier 'calculate corrections' node, which will pick this up during next iteration.\n\n// OUTPUT\nreturn msg; // to Home Battery flow",
"outputs": 1,
"timeout": 0,
"noerr": 0,