Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/04-setup-self-consumption.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 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. T<sub>u</sub> = 1 / `<resonant frequency>` and K<sub>u</sub> = your current K<sub>p</sub> 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.
Expand Down
6 changes: 5 additions & 1 deletion home assistant/packages/house_battery_control_config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,18 @@
# 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)
- name: "L1 Meter Power"
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) %}
Expand Down
2 changes: 1 addition & 1 deletion node-red/02 strategy-self-consumption.json

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions node-red/all-flows-in-one-file.json
Original file line number Diff line number Diff line change
Expand Up @@ -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 >= 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, `<font color=\"#009ac7\">Battery ${solution.id}</font>: 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,
Expand Down Expand Up @@ -12730,4 +12730,4 @@
"node-red-contrib-time-range-switch": "1.2.0"
}
}
]
]