From 6ced552909b2331a2f92765e8e6249a9f8089aee Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Wed, 3 Jun 2026 12:36:44 +0200 Subject: [PATCH 01/14] Feat: Add battery phase assignment support --- .DS_Store | Bin 6148 -> 0 bytes docs/06-advanced-features.md | 5 + home assistant/dashboard.yaml | 111 ++++++++++++--- .../packages/house_battery_control.yaml | 133 ++++++++++++++++++ node-red/01 start-flow.json | 94 ++++++++++++- node-red/all-flows-in-one-file.json | 94 ++++++++++++- 6 files changed, 405 insertions(+), 32 deletions(-) delete mode 100644 .DS_Store diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 689fa65a68e6d679245cb828e19b9c04bd439204..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK%}(1u5S~p!*$B1Dp;R1K;u_RIB}&zcmBI^PR1SbryN-&5@uJvi4pAf@;e~jV zK2N{d-Bci2j)hh;*6g=GGwa=NtsM^$so^x+Ch8H9hBCIgD1IY6&iX)Fp{D~>ZVp2U zRd{l!$iKhDPU>kjOeQDXI-9_Kbw|Cy}_05O9cZ)?=uW!EncsRN!E=zmuX;%0GoxBXVgd0>I z-CmKG+<`S_Az8WnsI<<1ZIfTquSuhvE&W6Z9Ld0Hc77NAy7CN5Ih_g)s0} G8Tbj)V_%j4 diff --git a/docs/06-advanced-features.md b/docs/06-advanced-features.md index 0e17303..caa3988 100644 --- a/docs/06-advanced-features.md +++ b/docs/06-advanced-features.md @@ -64,6 +64,10 @@ nav_order: 6 ## Multi-Battery Management - **More than 6 batteries:** Override or change `input_number.house_battery_count` and you are good to go. - The dashboard supports 6 batteries out of the box. For 7 or more, duplicate and edit these cards or create your own dashboard. +- **Manual phase assignment:** Assign each configured battery to `L1`, `L2`, `L3`, or `Unassigned` from the dashboard. + - The overview shows the assigned phase on each battery header and shows live battery power per phase in kW. + - The Node-RED battery object exposes this as `battery.phase`, so custom strategies can use the mapping. + - Built-in strategies still use aggregate control by default; per-phase control/peak shaving is not enforced yet. - **3-Phase self-consumption:** if you require 0 W grid consumption on a per phase basis, the setup changes slightly. Note: most homes get billed for the net total of all phases. If that is the case for you as well, ignore these instructions. @@ -94,6 +98,7 @@ Controls grid import/export thresholds for `peak shaving` functionality. - **Import limit:** Maximum power to draw from the grid (example: 16A × 230V = 3680W for CAPTAR contracts) - **Export limit:** Maximum power to feed back to the grid (example: 3000W if grid connection has export limits) +- **Max phase power:** Shared per-phase safety threshold for future/custom phase-aware strategies. This value is exposed to Node-RED as `msg.grid_power_limit_phase`, but built-in strategies do not enforce it yet. - **Configuration:** Adjust from the "Settings" tab in the Home Assistant dashboard ### Charge / Sell Power Mode diff --git a/home assistant/dashboard.yaml b/home assistant/dashboard.yaml index d661ddf..8266b53 100644 --- a/home assistant/dashboard.yaml +++ b/home assistant/dashboard.yaml @@ -410,9 +410,22 @@ views: columns: full rows: 1 - type: heading - heading: Marstek M1 + heading: Phase power heading_style: subtitle - icon: '' + icon: mdi:transmission-tower + - type: glance + entities: + - entity: sensor.house_battery_l1_power + name: L1 + - entity: sensor.house_battery_l2_power + name: L2 + - entity: sensor.house_battery_l3_power + name: L3 + columns: 3 + - type: markdown + content: >- + ### Marstek M1{% set phase = states('input_select.marstek_m1_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: state entity: sensor.marstek_m1_device_name @@ -423,6 +436,8 @@ views: name: M1 SoC - entity: sensor.marstek_m1_ac_power name: M1 Power + - entity: input_select.marstek_m1_phase + name: Phase - entity: number.marstek_m1_max_charge_power name: Max Charge - entity: number.marstek_m1_max_discharge_power @@ -431,10 +446,10 @@ views: - condition: state entity: sensor.marstek_m1_device_name state_not: unknown - - type: heading - heading: Marstek M2 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M2{% set phase = states('input_select.marstek_m2_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -450,6 +465,8 @@ views: name: M2 SoC - entity: sensor.marstek_m2_ac_power name: M2 Power + - entity: input_select.marstek_m2_phase + name: Phase - entity: number.marstek_m2_max_charge_power name: Max Charge - entity: number.marstek_m2_max_discharge_power @@ -463,10 +480,10 @@ views: - condition: numeric_state entity: input_number.house_battery_count above: 1 - - type: heading - heading: Marstek M3 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M3{% set phase = states('input_select.marstek_m3_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -482,6 +499,8 @@ views: name: M3 SoC - entity: sensor.marstek_m3_ac_power name: M3 Power + - entity: input_select.marstek_m3_phase + name: Phase - entity: number.marstek_m3_max_charge_power name: Max Charge - entity: number.marstek_m3_max_discharge_power @@ -495,10 +514,10 @@ views: - condition: numeric_state entity: input_number.house_battery_count above: 2 - - type: heading - heading: Marstek M4 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M4{% set phase = states('input_select.marstek_m4_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -514,6 +533,8 @@ views: name: M4 SoC - entity: sensor.marstek_m4_ac_power name: M4 Power + - entity: input_select.marstek_m4_phase + name: Phase - entity: number.marstek_m4_max_charge_power name: Max Charge - entity: number.marstek_m4_max_discharge_power @@ -527,10 +548,10 @@ views: - condition: numeric_state entity: input_number.house_battery_count above: 3 - - type: heading - heading: Marstek M5 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M5{% set phase = states('input_select.marstek_m5_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -546,6 +567,8 @@ views: name: M5 SoC - entity: sensor.marstek_m5_ac_power name: M5 Power + - entity: input_select.marstek_m5_phase + name: Phase - entity: number.marstek_m5_max_charge_power name: Max Charge - entity: number.marstek_m5_max_discharge_power @@ -559,10 +582,10 @@ views: - condition: numeric_state entity: input_number.house_battery_count above: 4 - - type: heading - heading: Marstek M6 - heading_style: subtitle - icon: '' + - type: markdown + content: >- + ### Marstek M6{% set phase = states('input_select.marstek_m6_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} + text_only: true visibility: - condition: and conditions: @@ -578,6 +601,8 @@ views: name: M6 SoC - entity: sensor.marstek_m6_ac_power name: M6 Power + - entity: input_select.marstek_m6_phase + name: Phase - entity: number.marstek_m6_max_charge_power name: Max Charge - entity: number.marstek_m6_max_discharge_power @@ -1848,6 +1873,24 @@ views: text_only: true grid_options: columns: full + - type: grid + cards: + - type: heading + icon: mdi:fuse + heading: Per-phase safety limit + heading_style: subtitle + - type: entities + entities: + - entity: input_number.house_battery_control_max_phase_power + name: Max phase power + - type: markdown + content: >- + Available for custom and future phase-aware + strategies. Built-in strategies do not enforce this limit + yet. + text_only: true + grid_options: + columns: full - type: grid cards: - type: heading @@ -1890,6 +1933,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M1 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m1_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -1959,6 +2006,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M2 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m2_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -2024,6 +2075,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M3 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m3_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -2089,6 +2144,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M4 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m4_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -2154,6 +2213,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M5 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m5_phase + name: Phase - type: vertical-stack cards: - type: markdown @@ -2219,6 +2282,10 @@ views: icon: mdi:power-plug-battery heading: Marstek M6 heading_style: title + - type: entities + entities: + - entity: input_select.marstek_m6_phase + name: Phase - type: vertical-stack cards: - type: markdown diff --git a/home assistant/packages/house_battery_control.yaml b/home assistant/packages/house_battery_control.yaml index eb3e011..3e32c36 100644 --- a/home assistant/packages/house_battery_control.yaml +++ b/home assistant/packages/house_battery_control.yaml @@ -388,6 +388,17 @@ input_number: mode: box icon: mdi:home-export-outline + # Peak shaving | per-phase threshold for custom/future phase-aware strategies + house_battery_control_max_phase_power: + name: Max phase power + min: 500 + max: 25000 + initial: 5500 + step: 1 + unit_of_measurement: "W" + mode: box + icon: mdi:transmission-tower + # Strategy charge | desired energy reserve (effective energy available excl. cut-off energies) # old name: house_battery_strategy_timed_target_energy house_battery_strategy_charge_target_energy: @@ -659,6 +670,41 @@ input_select: - Full control # Full control over the battery, no Marstek control icon: mdi:battery-heart + # Battery phase assignments + marstek_m1_phase: + name: "Marstek M1 Phase" + options: &BatteryPhaseOptions + - Unassigned + - L1 + - L2 + - L3 + icon: mdi:transmission-tower + + marstek_m2_phase: + name: "Marstek M2 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + + marstek_m3_phase: + name: "Marstek M3 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + + marstek_m4_phase: + name: "Marstek M4 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + + marstek_m5_phase: + name: "Marstek M5 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + + marstek_m6_phase: + name: "Marstek M6 Phase" + options: *BatteryPhaseOptions + icon: mdi:transmission-tower + # Master strategy house_battery_strategy: name: House Battery Strategy @@ -868,6 +914,93 @@ template: {# Marstek uses negative power when delivering power, positive when charging. This is inverse from what HA expects.#} {{ (total_power * -1) | round(2) }} + # Live battery power on phase L1 + - name: "House Battery L1 Power" + unique_id: "house_battery_l1_power_kw" + state_class: measurement + device_class: "power" + unit_of_measurement: "kW" + icon: mdi:transmission-tower + state: > + {% set total_w = namespace(value=0) %} + {% if states('input_select.marstek_m1_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m1_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m2_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m2_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m3_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m3_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m4_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m4_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m5_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m5_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m6_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_battery_power') | float(0)) %} + {% endif %} + {{ ((total_w.value * -1) / 1000) | round(2) }} + + # Live battery power on phase L2 + - name: "House Battery L2 Power" + unique_id: "house_battery_l2_power_kw" + state_class: measurement + device_class: "power" + unit_of_measurement: "kW" + icon: mdi:transmission-tower + state: > + {% set total_w = namespace(value=0) %} + {% if states('input_select.marstek_m1_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m1_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m2_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m2_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m3_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m3_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m4_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m4_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m5_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m5_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m6_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_battery_power') | float(0)) %} + {% endif %} + {{ ((total_w.value * -1) / 1000) | round(2) }} + + # Live battery power on phase L3 + - name: "House Battery L3 Power" + unique_id: "house_battery_l3_power_kw" + state_class: measurement + device_class: "power" + unit_of_measurement: "kW" + icon: mdi:transmission-tower + state: > + {% set total_w = namespace(value=0) %} + {% if states('input_select.marstek_m1_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m1_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m2_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m2_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m3_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m3_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m4_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m4_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m5_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m5_battery_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m6_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_battery_power') | float(0)) %} + {% endif %} + {{ ((total_w.value * -1) / 1000) | round(2) }} + # Time Remaining - name: "Battery time remaining" unique_id: house_battery_time_remaining diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index 3f899e2..7c9a3de 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -675,7 +675,7 @@ "z": "cf69560481408644", "g": "98643475a97c956a", "name": "Mapping", - "func": "// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", + "func": "// Normalize optional phase assignment\nconst normalizedPhase = String(msg.battery_phase || \"\").trim().toUpperCase();\nconst phase = [\"L1\", \"L2\", \"L3\"].includes(normalizedPhase) ? normalizedPhase : \"unassigned\";\n\n// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n phase: phase,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -1932,7 +1932,7 @@ "y": 500, "wires": [ [ - "43bc77e1a735d1d4" + "9eaf5b01ad50102a" ] ] }, @@ -1942,7 +1942,7 @@ "z": "cf69560481408644", "g": "98643475a97c956a", "name": "Loop end", - "func": "// cleanup \ndelete msg.battery_index;\n\ndelete msg.battery_power;\ndelete msg.max_charge_power;\ndelete msg.max_discharge_power;\ndelete msg.battery_state_of_charge;\ndelete msg.charging_cutoff_capacity;\ndelete msg.discharging_cutoff_capacity;\ndelete msg.inverter_state;\ndelete msg.battery_remaining_capacity;\ndelete msg.battery_total_energy;\ndelete msg.rs485_control_mode;\n\nreturn msg;", + "func": "// cleanup \ndelete msg.battery_index;\n\ndelete msg.battery_power;\ndelete msg.max_charge_power;\ndelete msg.max_discharge_power;\ndelete msg.battery_state_of_charge;\ndelete msg.charging_cutoff_capacity;\ndelete msg.discharging_cutoff_capacity;\ndelete msg.inverter_state;\ndelete msg.battery_remaining_capacity;\ndelete msg.battery_total_energy;\ndelete msg.rs485_control_mode;\ndelete msg.battery_phase;\ndelete msg.template;\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -3262,7 +3262,7 @@ "y": 1040, "wires": [ [ - "f7e09807696115eb" + "9eaf5b01ad50102c" ] ] }, @@ -3567,5 +3567,89 @@ "modules": { "node-red-contrib-home-assistant-websocket": "0.80.3" } + }, + { + "id": "9eaf5b01ad50102a", + "type": "function", + "z": "cf69560481408644", + "g": "2d7842e57b2a0b38", + "name": "Phase template", + "func": "// Prepare optional battery phase helper lookup\nmsg.template = `{{ states('input_select.marstek_m${msg.battery_index}_phase') }}`;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1760, + "y": 500, + "wires": [ + [ + "9eaf5b01ad50102b" + ] + ] + }, + { + "id": "9eaf5b01ad50102b", + "type": "api-render-template", + "z": "cf69560481408644", + "g": "2d7842e57b2a0b38", + "name": "Phase", + "server": "176d29a.6f648d6", + "version": 0, + "template": "", + "resultsLocation": "battery_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 1920, + "y": 500, + "wires": [ + [ + "43bc77e1a735d1d4" + ] + ] + }, + { + "id": "9eaf5b01ad50102c", + "type": "api-render-template", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Max phase power", + "server": "176d29a.6f648d6", + "version": 0, + "template": "{{ states('input_number.house_battery_control_max_phase_power') | float(5500) }}", + "resultsLocation": "grid_power_limit_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 880, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102d" + ] + ] + }, + { + "id": "9eaf5b01ad50102d", + "type": "function", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Normalize phase limit", + "func": "const phaseLimit = Number(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nRED.util.setMessageProperty(\n msg,\n \"grid_power_limit_phase\",\n Number.isFinite(phaseLimit) && phaseLimit > 0 ? phaseLimit : 5500,\n true\n);\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1080, + "y": 1040, + "wires": [ + [ + "f7e09807696115eb" + ] + ] } -] \ No newline at end of file +] diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index 2ffe90b..7cbed2a 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -3109,7 +3109,7 @@ "z": "419f395a5f52024b", "g": "a7c766e9d6b9fce6", "name": "Mapping", - "func": "// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", + "func": "// Normalize optional phase assignment\nconst normalizedPhase = String(msg.battery_phase || \"\").trim().toUpperCase();\nconst phase = [\"L1\", \"L2\", \"L3\"].includes(normalizedPhase) ? normalizedPhase : \"unassigned\";\n\n// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n phase: phase,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4366,7 +4366,7 @@ "y": 500, "wires": [ [ - "fea927e3388ed2c9" + "9eaf5b01ad50102a" ] ] }, @@ -4376,7 +4376,7 @@ "z": "419f395a5f52024b", "g": "a7c766e9d6b9fce6", "name": "Loop end", - "func": "// cleanup \ndelete msg.battery_index;\n\ndelete msg.battery_power;\ndelete msg.max_charge_power;\ndelete msg.max_discharge_power;\ndelete msg.battery_state_of_charge;\ndelete msg.charging_cutoff_capacity;\ndelete msg.discharging_cutoff_capacity;\ndelete msg.inverter_state;\ndelete msg.battery_remaining_capacity;\ndelete msg.battery_total_energy;\ndelete msg.rs485_control_mode;\n\nreturn msg;", + "func": "// cleanup \ndelete msg.battery_index;\n\ndelete msg.battery_power;\ndelete msg.max_charge_power;\ndelete msg.max_discharge_power;\ndelete msg.battery_state_of_charge;\ndelete msg.charging_cutoff_capacity;\ndelete msg.discharging_cutoff_capacity;\ndelete msg.inverter_state;\ndelete msg.battery_remaining_capacity;\ndelete msg.battery_total_energy;\ndelete msg.rs485_control_mode;\ndelete msg.battery_phase;\ndelete msg.template;\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -5696,7 +5696,7 @@ "y": 1040, "wires": [ [ - "2c2ed88ff158d0b7" + "9eaf5b01ad50102c" ] ] }, @@ -16037,5 +16037,89 @@ "x": 165, "y": 320, "wires": [] + }, + { + "id": "9eaf5b01ad50102a", + "type": "function", + "z": "419f395a5f52024b", + "g": "261b10d232e1c04e", + "name": "Phase template", + "func": "// Prepare optional battery phase helper lookup\nmsg.template = `{{ states('input_select.marstek_m${msg.battery_index}_phase') }}`;\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1760, + "y": 500, + "wires": [ + [ + "9eaf5b01ad50102b" + ] + ] + }, + { + "id": "9eaf5b01ad50102b", + "type": "api-render-template", + "z": "419f395a5f52024b", + "g": "261b10d232e1c04e", + "name": "Phase", + "server": "176d29a.6f648d6", + "version": 0, + "template": "", + "resultsLocation": "battery_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 1920, + "y": 500, + "wires": [ + [ + "fea927e3388ed2c9" + ] + ] + }, + { + "id": "9eaf5b01ad50102c", + "type": "api-render-template", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Max phase power", + "server": "176d29a.6f648d6", + "version": 0, + "template": "{{ states('input_number.house_battery_control_max_phase_power') | float(5500) }}", + "resultsLocation": "grid_power_limit_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 880, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102d" + ] + ] + }, + { + "id": "9eaf5b01ad50102d", + "type": "function", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Normalize phase limit", + "func": "const phaseLimit = Number(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nRED.util.setMessageProperty(\n msg,\n \"grid_power_limit_phase\",\n Number.isFinite(phaseLimit) && phaseLimit > 0 ? phaseLimit : 5500,\n true\n);\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1080, + "y": 1040, + "wires": [ + [ + "2c2ed88ff158d0b7" + ] + ] } -] \ No newline at end of file +] From ba1fad7262ae22a8346fcc18cf82a7497a7d9ff2 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Wed, 3 Jun 2026 13:33:11 +0200 Subject: [PATCH 02/14] Expose per-phase info from meter --- docs/06-advanced-features.md | 2 + home assistant/dashboard.yaml | 14 +----- .../packages/house_battery_control.yaml | 5 ++ .../house_battery_control_config.yaml | 25 ++++++++++ node-red/01 start-flow.json | 50 ++++++++++++++++++- node-red/all-flows-in-one-file.json | 50 ++++++++++++++++++- 6 files changed, 129 insertions(+), 17 deletions(-) diff --git a/docs/06-advanced-features.md b/docs/06-advanced-features.md index caa3988..860d925 100644 --- a/docs/06-advanced-features.md +++ b/docs/06-advanced-features.md @@ -67,6 +67,8 @@ nav_order: 6 - **Manual phase assignment:** Assign each configured battery to `L1`, `L2`, `L3`, or `Unassigned` from the dashboard. - The overview shows the assigned phase on each battery header and shows live battery power per phase in kW. - The Node-RED battery object exposes this as `battery.phase`, so custom strategies can use the mapping. + - Optional per-phase grid power aliases can be configured in `packages/house_battery_control_config.yaml` as `sensor.p1_meter_l1_power`, `sensor.p1_meter_l2_power`, and `sensor.p1_meter_l3_power`. Leave them commented out if your setup is not three-phase. + - Node-RED exposes configured phase meter values as `msg.grid_power_phase.L1`, `.L2`, and `.L3`, with missing or unreadable values set to `null`. - Built-in strategies still use aggregate control by default; per-phase control/peak shaving is not enforced yet. - **3-Phase self-consumption:** if you require 0 W grid consumption on a per phase basis, the setup changes slightly. diff --git a/home assistant/dashboard.yaml b/home assistant/dashboard.yaml index 8266b53..08ddd08 100644 --- a/home assistant/dashboard.yaml +++ b/home assistant/dashboard.yaml @@ -410,7 +410,7 @@ views: columns: full rows: 1 - type: heading - heading: Phase power + heading: Battery Phase Interaction heading_style: subtitle icon: mdi:transmission-tower - type: glance @@ -436,8 +436,6 @@ views: name: M1 SoC - entity: sensor.marstek_m1_ac_power name: M1 Power - - entity: input_select.marstek_m1_phase - name: Phase - entity: number.marstek_m1_max_charge_power name: Max Charge - entity: number.marstek_m1_max_discharge_power @@ -465,8 +463,6 @@ views: name: M2 SoC - entity: sensor.marstek_m2_ac_power name: M2 Power - - entity: input_select.marstek_m2_phase - name: Phase - entity: number.marstek_m2_max_charge_power name: Max Charge - entity: number.marstek_m2_max_discharge_power @@ -499,8 +495,6 @@ views: name: M3 SoC - entity: sensor.marstek_m3_ac_power name: M3 Power - - entity: input_select.marstek_m3_phase - name: Phase - entity: number.marstek_m3_max_charge_power name: Max Charge - entity: number.marstek_m3_max_discharge_power @@ -533,8 +527,6 @@ views: name: M4 SoC - entity: sensor.marstek_m4_ac_power name: M4 Power - - entity: input_select.marstek_m4_phase - name: Phase - entity: number.marstek_m4_max_charge_power name: Max Charge - entity: number.marstek_m4_max_discharge_power @@ -567,8 +559,6 @@ views: name: M5 SoC - entity: sensor.marstek_m5_ac_power name: M5 Power - - entity: input_select.marstek_m5_phase - name: Phase - entity: number.marstek_m5_max_charge_power name: Max Charge - entity: number.marstek_m5_max_discharge_power @@ -601,8 +591,6 @@ views: name: M6 SoC - entity: sensor.marstek_m6_ac_power name: M6 Power - - entity: input_select.marstek_m6_phase - name: Phase - entity: number.marstek_m6_max_charge_power name: Max Charge - entity: number.marstek_m6_max_discharge_power diff --git a/home assistant/packages/house_battery_control.yaml b/home assistant/packages/house_battery_control.yaml index 3e32c36..2980520 100644 --- a/home assistant/packages/house_battery_control.yaml +++ b/home assistant/packages/house_battery_control.yaml @@ -914,6 +914,11 @@ template: {# Marstek uses negative power when delivering power, positive when charging. This is inverse from what HA expects.#} {{ (total_power * -1) | round(2) }} + # Battery-side phase interaction totals. + # Optional grid-side phase meter aliases are configured in + # house_battery_control_config.yaml as sensor.p1_meter_l1_power, + # sensor.p1_meter_l2_power and sensor.p1_meter_l3_power. + # Live battery power on phase L1 - name: "House Battery L1 Power" unique_id: "house_battery_l1_power_kw" diff --git a/home assistant/packages/house_battery_control_config.yaml b/home assistant/packages/house_battery_control_config.yaml index 7572993..8385ef5 100644 --- a/home assistant/packages/house_battery_control_config.yaml +++ b/home assistant/packages/house_battery_control_config.yaml @@ -36,3 +36,28 @@ template: unit_of_measurement: "W" device_class: "power" state_class: "measurement" + + # House battery control | Optional per-phase grid power + # Uncomment these aliases if your three-phase meter exposes net power per phase. + # Positive = import from grid, negative = export to grid. + # Leave these blocks commented out if you do not have a three-phase setup. + # - name: "P1 Meter L1 Power" + # unique_id: "p1_meter_l1_power" + # state: "{{ states('sensor.your_l1_phase_power_sensor') }}" # << SET YOUR L1 PHASE POWER SENSOR HERE + # unit_of_measurement: "W" + # device_class: "power" + # state_class: "measurement" + + # - name: "P1 Meter L2 Power" + # unique_id: "p1_meter_l2_power" + # state: "{{ states('sensor.your_l2_phase_power_sensor') }}" # << SET YOUR L2 PHASE POWER SENSOR HERE + # unit_of_measurement: "W" + # device_class: "power" + # state_class: "measurement" + + # - name: "P1 Meter L3 Power" + # unique_id: "p1_meter_l3_power" + # state: "{{ states('sensor.your_l3_phase_power_sensor') }}" # << SET YOUR L3 PHASE POWER SENSOR HERE + # unit_of_measurement: "W" + # device_class: "power" + # state_class: "measurement" diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index 7c9a3de..d5b19fe 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -317,11 +317,15 @@ "f6f3ce29833ec4cf", "5ac3cdf0ad6013e3", "69d4aa99b46127ba", - "783deb36dab080de" + "783deb36dab080de", + "9eaf5b01ad50102c", + "9eaf5b01ad50102d", + "9eaf5b01ad50102e", + "9eaf5b01ad50102f" ], "x": 34, "y": 999, - "w": 752, + "w": 1642, "h": 82 }, { @@ -3646,6 +3650,48 @@ "libs": [], "x": 1080, "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102e" + ] + ] + }, + { + "id": "9eaf5b01ad50102e", + "type": "api-render-template", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Phase grid power", + "server": "176d29a.6f648d6", + "version": 0, + "template": "{%- macro phase_power(entity_id) -%}\n{%- set raw = states(entity_id) -%}\n{%- if raw in ['unknown', 'unavailable', 'none', 'None', ''] -%}\nnull\n{%- elif is_number(raw) -%}\n{{ raw | float }}\n{%- else -%}\nnull\n{%- endif -%}\n{%- endmacro -%}\n{\"L1\": {{ phase_power('sensor.p1_meter_l1_power') }}, \"L2\": {{ phase_power('sensor.p1_meter_l2_power') }}, \"L3\": {{ phase_power('sensor.p1_meter_l3_power') }}}", + "resultsLocation": "grid_power_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 1290, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102f" + ] + ] + }, + { + "id": "9eaf5b01ad50102f", + "type": "function", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Normalize phase power", + "func": "let phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\");\n\nif (typeof phasePower === \"string\") {\n const rawPhasePower = phasePower.trim();\n if (rawPhasePower.length > 0) {\n try {\n phasePower = JSON.parse(rawPhasePower);\n } catch (error) {\n phasePower = {};\n }\n } else {\n phasePower = {};\n }\n}\n\nif (!phasePower || typeof phasePower !== \"object\") {\n phasePower = {};\n}\n\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\n\nRED.util.setMessageProperty(msg, \"grid_power_phase\", {\n L1: toNumberOrNull(phasePower.L1 ?? phasePower.l1),\n L2: toNumberOrNull(phasePower.L2 ?? phasePower.l2),\n L3: toNumberOrNull(phasePower.L3 ?? phasePower.l3),\n}, true);\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1510, + "y": 1040, "wires": [ [ "f7e09807696115eb" diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index 7cbed2a..52e2c6c 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -1342,11 +1342,15 @@ "a6203334d934a28a", "d8d5fbfc33432345", "783d88edb917dab9", - "f496519e76d1c4fa" + "f496519e76d1c4fa", + "9eaf5b01ad50102c", + "9eaf5b01ad50102d", + "9eaf5b01ad50102e", + "9eaf5b01ad50102f" ], "x": 34, "y": 999, - "w": 752, + "w": 1642, "h": 82 }, { @@ -16116,6 +16120,48 @@ "libs": [], "x": 1080, "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102e" + ] + ] + }, + { + "id": "9eaf5b01ad50102e", + "type": "api-render-template", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Phase grid power", + "server": "176d29a.6f648d6", + "version": 0, + "template": "{%- macro phase_power(entity_id) -%}\n{%- set raw = states(entity_id) -%}\n{%- if raw in ['unknown', 'unavailable', 'none', 'None', ''] -%}\nnull\n{%- elif is_number(raw) -%}\n{{ raw | float }}\n{%- else -%}\nnull\n{%- endif -%}\n{%- endmacro -%}\n{\"L1\": {{ phase_power('sensor.p1_meter_l1_power') }}, \"L2\": {{ phase_power('sensor.p1_meter_l2_power') }}, \"L3\": {{ phase_power('sensor.p1_meter_l3_power') }}}", + "resultsLocation": "grid_power_phase", + "resultsLocationType": "msg", + "templateLocation": "", + "templateLocationType": "none", + "x": 1290, + "y": 1040, + "wires": [ + [ + "9eaf5b01ad50102f" + ] + ] + }, + { + "id": "9eaf5b01ad50102f", + "type": "function", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Normalize phase power", + "func": "let phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\");\n\nif (typeof phasePower === \"string\") {\n const rawPhasePower = phasePower.trim();\n if (rawPhasePower.length > 0) {\n try {\n phasePower = JSON.parse(rawPhasePower);\n } catch (error) {\n phasePower = {};\n }\n } else {\n phasePower = {};\n }\n}\n\nif (!phasePower || typeof phasePower !== \"object\") {\n phasePower = {};\n}\n\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\n\nRED.util.setMessageProperty(msg, \"grid_power_phase\", {\n L1: toNumberOrNull(phasePower.L1 ?? phasePower.l1),\n L2: toNumberOrNull(phasePower.L2 ?? phasePower.l2),\n L3: toNumberOrNull(phasePower.L3 ?? phasePower.l3),\n}, true);\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1510, + "y": 1040, "wires": [ [ "2c2ed88ff158d0b7" From cb7e37a43318a627c8ec45618c125c4165eb28e6 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Wed, 3 Jun 2026 14:33:44 +0200 Subject: [PATCH 03/14] Hide phase interaction when no phases assigned --- home assistant/dashboard.yaml | 8 ++++++++ home assistant/packages/house_battery_control.yaml | 12 ++++++++++++ 2 files changed, 20 insertions(+) diff --git a/home assistant/dashboard.yaml b/home assistant/dashboard.yaml index 08ddd08..1189274 100644 --- a/home assistant/dashboard.yaml +++ b/home assistant/dashboard.yaml @@ -413,6 +413,10 @@ views: heading: Battery Phase Interaction heading_style: subtitle icon: mdi:transmission-tower + visibility: + - condition: state + entity: binary_sensor.house_battery_has_phase_assignment + state: 'on' - type: glance entities: - entity: sensor.house_battery_l1_power @@ -422,6 +426,10 @@ views: - entity: sensor.house_battery_l3_power name: L3 columns: 3 + visibility: + - condition: state + entity: binary_sensor.house_battery_has_phase_assignment + state: 'on' - type: markdown content: >- ### Marstek M1{% set phase = states('input_select.marstek_m1_phase') %}{% if phase == 'L1' %} - Phase 1{% elif phase == 'L2' %} - Phase 2{% elif phase == 'L3' %} - Phase 3{% endif %} diff --git a/home assistant/packages/house_battery_control.yaml b/home assistant/packages/house_battery_control.yaml index 2980520..dd669f5 100644 --- a/home assistant/packages/house_battery_control.yaml +++ b/home assistant/packages/house_battery_control.yaml @@ -1097,6 +1097,18 @@ template: {{ [max_available - filled, 0] | max | round(2) if max_available > 0 else 0 }} - binary_sensor: + # Battery phase assignments | has any battery been assigned to a phase? + - name: "House Battery Has Phase Assignment" + unique_id: house_battery_has_phase_assignment + icon: mdi:transmission-tower + state: > + {{ states('input_select.marstek_m1_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m2_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m3_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m4_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m5_phase') in ['L1', 'L2', 'L3'] or + states('input_select.marstek_m6_phase') in ['L1', 'L2', 'L3'] }} + # Strategy dynamic | is the avg tariff low enough to call this a 'cheap period' - name: "Is below threshold cheapest period tariff" unique_id: house_battery_strategy_dynamic_is_below_threshold_cheapest_tariff From b37c8da8dc4f317b18e7c2fd921f4c2bc54e6e15 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Wed, 3 Jun 2026 14:38:12 +0200 Subject: [PATCH 04/14] Remove tracked macOS metadata --- .gitignore | 3 ++- home assistant/.DS_Store | Bin 6148 -> 0 bytes 2 files changed, 2 insertions(+), 1 deletion(-) delete mode 100644 home assistant/.DS_Store diff --git a/.gitignore b/.gitignore index 3565fd3..57472ed 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,11 @@ RELEASE_NOTES_TEMP.txt # Ignore notes and development files notes/ template_sensor_bob_battery_dashboard.yaml +.DS_Store # Ignore SASS cache files docs/.sass-cache # Claude Code per-user state — keep local; commands/skills/agents/settings.json are shared .claude/settings.local.json -.claude/sessions/ \ No newline at end of file +.claude/sessions/ diff --git a/home assistant/.DS_Store b/home assistant/.DS_Store deleted file mode 100644 index 8c1d6d6615afb76919813ad015c0434f8bdb3985..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHKOHRWu5FM8;D!yH^3`gh~8IpbDr0 zzfA#NyT`PkB~`Sh_WGUE9a`(Wo)ww31s3@3z~X)~o!PQ#G2A(DY11?-Y=-#J;NtD- zIe9tno1?V(w{xn8&i^_I2wfR4b+bU z>Px)y5*CLj-4x1sjiU z>p6fnbdnkJwmXKRNXpS2PE7S6_T mjYpS)LXKls;iLEz?hJhqSAel#;}I<|{Sa_6XrT)Hr~>bHo{Db( From c66abcdf7d9090bfb8c0eee67bdb094bda1766e9 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Sun, 7 Jun 2026 13:30:02 +0200 Subject: [PATCH 05/14] Add per-phase peak shaving protection --- docs/06-advanced-features.md | 5 +- home assistant/dashboard.yaml | 7 +- .../packages/house_battery_control.yaml | 4 ++ node-red/01 start-flow.json | 56 +++++++++++++++- node-red/02 strategy-partials.json | 8 +-- node-red/02 strategy-self-consumption.json | 6 +- node-red/all-flows-in-one-file.json | 66 +++++++++++++++++-- 7 files changed, 131 insertions(+), 21 deletions(-) diff --git a/docs/06-advanced-features.md b/docs/06-advanced-features.md index 860d925..9a8f5fb 100644 --- a/docs/06-advanced-features.md +++ b/docs/06-advanced-features.md @@ -69,7 +69,7 @@ nav_order: 6 - The Node-RED battery object exposes this as `battery.phase`, so custom strategies can use the mapping. - Optional per-phase grid power aliases can be configured in `packages/house_battery_control_config.yaml` as `sensor.p1_meter_l1_power`, `sensor.p1_meter_l2_power`, and `sensor.p1_meter_l3_power`. Leave them commented out if your setup is not three-phase. - Node-RED exposes configured phase meter values as `msg.grid_power_phase.L1`, `.L2`, and `.L3`, with missing or unreadable values set to `null`. - - Built-in strategies still use aggregate control by default; per-phase control/peak shaving is not enforced yet. + - Built-in strategies still use aggregate control by default. Enable per-phase peak shaving to let peak shaving also react to phase-level power limits. - **3-Phase self-consumption:** if you require 0 W grid consumption on a per phase basis, the setup changes slightly. Note: most homes get billed for the net total of all phases. If that is the case for you as well, ignore these instructions. @@ -100,7 +100,8 @@ Controls grid import/export thresholds for `peak shaving` functionality. - **Import limit:** Maximum power to draw from the grid (example: 16A × 230V = 3680W for CAPTAR contracts) - **Export limit:** Maximum power to feed back to the grid (example: 3000W if grid connection has export limits) -- **Max phase power:** Shared per-phase safety threshold for future/custom phase-aware strategies. This value is exposed to Node-RED as `msg.grid_power_limit_phase`, but built-in strategies do not enforce it yet. +- **Max phase power:** Shared per-phase safety threshold. This value is exposed to Node-RED as `msg.grid_power_limit_phase` and is used by per-phase peak shaving when enabled. +- **Per-phase peak shaving:** Optional protection that uses configured L1/L2/L3 grid power sensors and battery phase assignments to reduce an overloaded phase. Missing phase sensors, unassigned batteries, or the feature being disabled keep the existing aggregate-only behavior. Unavailable batteries are treated as 0W assignable capacity, so remaining batteries on the same phase are asked to carry the correction. - **Configuration:** Adjust from the "Settings" tab in the Home Assistant dashboard ### Charge / Sell Power Mode diff --git a/home assistant/dashboard.yaml b/home assistant/dashboard.yaml index 1189274..08084f8 100644 --- a/home assistant/dashboard.yaml +++ b/home assistant/dashboard.yaml @@ -1877,13 +1877,14 @@ views: heading_style: subtitle - type: entities entities: + - entity: input_boolean.house_battery_control_has_phase_protection - entity: input_number.house_battery_control_max_phase_power name: Max phase power - type: markdown content: >- - Available for custom and future phase-aware - strategies. Built-in strategies do not enforce this limit - yet. + When enabled, peak shaving also considers + configured L1/L2/L3 grid power sensors and phase-assigned + batteries. Leave disabled for aggregate-only behavior. text_only: true grid_options: columns: full diff --git a/home assistant/packages/house_battery_control.yaml b/home assistant/packages/house_battery_control.yaml index dd669f5..2f69e10 100644 --- a/home assistant/packages/house_battery_control.yaml +++ b/home assistant/packages/house_battery_control.yaml @@ -38,6 +38,10 @@ input_boolean: name: Enable export peak shaving icon: mdi:transmission-tower-export + house_battery_control_has_phase_protection: + name: Enable per-phase peak shaving + icon: mdi:fuse + # house_battery_control_has_power_limit_during_ev_charge: # name: Enable peak shaving during EV charge # icon: mdi:ev-station diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index d5b19fe..d685784 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -321,11 +321,12 @@ "9eaf5b01ad50102c", "9eaf5b01ad50102d", "9eaf5b01ad50102e", - "9eaf5b01ad50102f" + "9eaf5b01ad50102f", + "9eaf5b01ad501030" ], "x": 34, "y": 999, - "w": 1642, + "w": 1862, "h": 82 }, { @@ -3692,6 +3693,57 @@ "libs": [], "x": 1510, "y": 1040, + "wires": [ + [ + "9eaf5b01ad501030" + ] + ] + }, + { + "id": "9eaf5b01ad501030", + "type": "api-current-state", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Phase protection?", + "server": "176d29a.6f648d6", + "version": 3, + "outputs": 1, + "halt_if": "", + "halt_if_type": "str", + "halt_if_compare": "is", + "entity_id": "input_boolean.house_battery_control_has_phase_protection", + "state_type": "str", + "blockInputOverrides": true, + "outputProperties": [ + { + "property": "payload", + "propertyType": "msg", + "value": "string", + "valueType": "entityState" + }, + { + "property": "data", + "propertyType": "msg", + "value": "", + "valueType": "entity" + }, + { + "property": "phase_protection.enabled", + "propertyType": "msg", + "value": "$entity().state = \"on\"", + "valueType": "jsonata" + } + ], + "for": "0", + "forType": "num", + "forUnits": "minutes", + "override_topic": false, + "state_location": "payload", + "override_payload": "msg", + "entity_location": "data", + "override_data": "msg", + "x": 1730, + "y": 1040, "wires": [ [ "f7e09807696115eb" diff --git a/node-red/02 strategy-partials.json b/node-red/02 strategy-partials.json index 48e5327..265c602 100644 --- a/node-red/02 strategy-partials.json +++ b/node-red/02 strategy-partials.json @@ -341,7 +341,7 @@ "type": "function", "z": "1ae53b821b8673a2", "name": "Mode selection", - "func": "/* Mode selection \n * \n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * \n * Peak shaving will persist while:\n * - Grid power exceeds the import/export limit set by the user\n * - Batteries are applied to keep system below the limit set by the user\n * \n * The first is checked in this node.\n * The latter is checked in the last function node of the Peak Shaving flow.\n * Both set the `last_power_limit_violation` flow variable to Date.now() to persist peak shaving.\n * \n * PEAK_SHAVING_TIMEOUT_SEC is advised to be kept above atleast 3-5 seconds, to account for rate limiters and other power saving options.\n*/\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\nlet powerLimit = 0;\n\n// Input data | grid\nconst P1_power = parseInt(RED.util.getMessageProperty(msg, \"grid_power\") || 0);\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_import\") || 0);\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_export\") || 0);\n\n\n// ACTIVATE shaving\nif (hasImportLimit && (P1_power > importLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n // powerLimit = importLimit;\n}\nif (hasExportLimit && (P1_power < exportLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n // powerLimit = exportLimit;\n}\n\n// Export or Import\nRED.util.setMessageProperty(msg, \"strategy.peak_direction\", P1_power > 0 ? \"import\":\"export\", true);\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = P1_power > 0 ? importLimit : exportLimit;\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation ? `Peak Shaving` :`Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving); \nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", + "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\nif (hasImportLimit && (aggregateImportRequired > 0 || importPhaseRequired > 0)) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (hasExportLimit && exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -391,7 +391,7 @@ "z": "1ae53b821b8673a2", "g": "6e72815f5b5e92d2", "name": "Peak shaving", - "func": "// Logger\nconst log = global.get(\"logger\");\n\n// Explain\nlog(this,`**Peak shaving mode** postponing '${msg.target}' strategy`);\nnode.status({ fill: \"green\", shape: \"dot\", text: `postponing '${msg.target}' strategy`});\n\n// Original target\nconst ORIGINAL_TARGET = msg.target;\n\n// Use the PID controller strategy\nmsg.target = \"Self-consumption\";\n// Set the grid power limit\nmsg.house_target_grid_consumption_in_w = msg.payload;\n\n// Type of peak shave\nconst peakDirection = RED.util.getMessageProperty(msg, \"strategy.peak_direction\");\nconst DISABLED = true;\n\nswitch (peakDirection) {\n case \"import\":\n // Ask PID to help shave import peak\n RED.util.setMessageProperty(msg, \"advanced_settings.charge_disabled\", DISABLED, true); \n break;\n case \"export\":\n // Ask PID to help shave export peak\n RED.util.setMessageProperty(msg, \"advanced_settings.discharge_disabled\", DISABLED, true);\n break;\n default:\n log(this, `Unknow peak type ${peakDirection}, peak shave failed`, \"warn\"); \n node.status({ fill: \"red\", shape: \"dot\", text: `peak type ${peakDirection}, peak shave failed`});\n // Set a safe value\n msg.house_target_grid_consumption_in_w = 0;\n}\n\nreturn msg;", + "func": "// Logger\nconst log = global.get(\"logger\");\n\n// Explain\nlog(this,`**Peak shaving mode** postponing '${msg.target}' strategy`);\nnode.status({ fill: \"green\", shape: \"dot\", text: `postponing '${msg.target}' strategy`});\n\n// Original target\nconst ORIGINAL_TARGET = msg.target;\n\n// Use the PID controller strategy\nmsg.target = \"Self-consumption\";\n// Set the grid power limit\nmsg.house_target_grid_consumption_in_w = msg.payload;\n\n// Type of peak shave\nconst peakDirection = RED.util.getMessageProperty(msg, \"strategy.peak_direction\");\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst DISABLED = true;\n\nif (phaseProtection.active) {\n RED.util.setMessageProperty(msg, \"advanced_settings.phase_protection\", {\n direction: phaseProtection.direction,\n active_phases: phaseProtection.active_phases || [],\n required_by_phase: phaseProtection.required_by_phase || {},\n required_phase_power: phaseProtection.required_phase_power || 0,\n aggregate_residual_power: phaseProtection.aggregate_residual_power || 0,\n required_total_power: phaseProtection.required_total_power || 0,\n }, true);\n}\n\nswitch (peakDirection) {\n case \"import\":\n // Ask PID to help shave import peak\n RED.util.setMessageProperty(msg, \"advanced_settings.charge_disabled\", DISABLED, true);\n break;\n case \"export\":\n // Ask PID to help shave export peak\n RED.util.setMessageProperty(msg, \"advanced_settings.discharge_disabled\", DISABLED, true);\n break;\n default:\n log(this, `Unknow peak type ${peakDirection}, peak shave failed`, \"warn\");\n node.status({ fill: \"red\", shape: \"dot\", text: `peak type ${peakDirection}, peak shave failed`});\n // Set a safe value\n msg.house_target_grid_consumption_in_w = 0;\n}\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -429,7 +429,7 @@ "z": "1ae53b821b8673a2", "g": "6e72815f5b5e92d2", "name": "Peak check", - "func": "// Logger\nconst log = global.get(\"logger\");\n\n// INPUT\nconst PID_assigned = msg.pid?.load_assigned ?? 0; // calculated battery power delivery, see footnote_1 why this is 0 by default\nconst isCharging = msg.batteries_charging || false;\n\n// HOLD peak shave when:\nif(PID_assigned > 0 && !isCharging) {\n // The batteries are still required to reduce the power peak\n log(this,`**Peak shaving continues**, batteries still required for peak reduction`);\n // Reset timeout timer\n flow.set(\"last_power_limit_violation\", Date.now()); \n // UI\n node.status({fill:\"yellow\",shape:\"dot\",text:`Holding peak shave, batteries still required for ${PID_assigned} W`});\n} else {\n // No solution or batteries not required - let timer run out\n node.status({fill:\"green\",shape:\"dot\",text:`Relax`});\n}\n\nreturn msg;\n\n// FOOTNOTE_1: \n// Self-consumption won't return `msg.solutions` nor `msg.pid.load_assigned` when:\n// - P1 is near or at it's target grid consumption (aka inside deadband)\n// - when a non-blocking error occurs\n// \n// This code does not specifically account for these cases, but applies a TIMEOUT mechanism instead.\n// As PID_assigned is kept 0, the timer won't be updated and runs out.", + "func": "// Logger\nconst log = global.get(\"logger\");\n\n// INPUT\nconst PID_assigned = msg.pid?.load_assigned ?? 0; // calculated battery power delivery, see footnote_1 why this is 0 by default\nconst isCharging = msg.batteries_charging || false;\nconst phaseProtectionActive = RED.util.getMessageProperty(msg, \"phase_protection.active\") === true;\n\n// HOLD peak shave when:\nif(PID_assigned > 0 && (!isCharging || phaseProtectionActive)) {\n // The batteries are still required to reduce the power peak\n log(this,`**Peak shaving continues**, batteries still required for peak reduction`);\n // Reset timeout timer\n flow.set(\"last_power_limit_violation\", Date.now());\n // UI\n node.status({fill:\"yellow\",shape:\"dot\",text:`Holding peak shave, batteries still required for ${PID_assigned} W`});\n} else {\n // No solution or batteries not required - let timer run out\n node.status({fill:\"green\",shape:\"dot\",text:`Relax`});\n}\n\nreturn msg;\n\n// FOOTNOTE_1:\n// Self-consumption won't return `msg.solutions` nor `msg.pid.load_assigned` when:\n// - P1 is near or at it's target grid consumption (aka inside deadband)\n// - when a non-blocking error occurs\n//\n// This code does not specifically account for these cases, but applies a TIMEOUT mechanism instead.\n// As PID_assigned is kept 0, the timer won't be updated and runs out.", "outputs": 1, "timeout": 0, "noerr": 0, @@ -672,4 +672,4 @@ "wires": [], "l": false } -] \ No newline at end of file +] diff --git a/node-red/02 strategy-self-consumption.json b/node-red/02 strategy-self-consumption.json index 8c3d154..b102ecc 100644 --- a/node-red/02 strategy-self-consumption.json +++ b/node-red/02 strategy-self-consumption.json @@ -1756,7 +1756,7 @@ "z": "08bd910806aaf74c", "g": "e3296059eb36b879", "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;", + "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// Phase protection is a safety-oriented peak-shave path. Do not let output\n// dampening reduce the correction below the immediate phase requirement.\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseRequired = Number(phaseProtection.required_total_power) || 0;\nif (phaseProtection.active === true && phaseRequired > 0) {\n if (phaseProtection.direction === \"import\" && PID_filtered > -phaseRequired) {\n PID_filtered = -phaseRequired;\n } else if (phaseProtection.direction === \"export\" && PID_filtered < phaseRequired) {\n PID_filtered = phaseRequired;\n }\n}\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, @@ -1820,7 +1820,7 @@ "z": "08bd910806aaf74c", "g": "a53d59ea9a6ce336", "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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? 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?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nvar unassigned_power = unassigned_power_initial; // Before distribution\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 >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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) {\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// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true); \nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(unassigned_power_initial - unassigned_power), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassigned_power,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n batteries_total_assignable_power += Number(assignablePower);\n\n return {\n battery,\n phase: getPhase(battery),\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nfunction assignPower(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n return assignedPower;\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n [\"L1\", \"L2\", \"L3\"].forEach((phase) => {\n let phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n batteryStates\n .filter((state) => state.phase === phase)\n .forEach((state) => {\n const assignedPower = assignPower(state, phaseRequiredPower);\n phaseRequiredPower -= assignedPower;\n phaseAssignment[phase].assigned += assignedPower;\n });\n\n phaseAssignment[phase].unassigned = phaseRequiredPower;\n if (phaseRequiredPower > 0) {\n log(this, `Phase protection ${phase}: ${phaseRequiredPower} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nbatteryStates.forEach((state) => {\n if (aggregatePowerToAssign <= 0) return;\n const assignedPower = assignPower(state, aggregatePowerToAssign);\n aggregatePowerToAssign -= assignedPower;\n aggregateAssignedPower += assignedPower;\n});\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, @@ -2388,4 +2388,4 @@ "node-red-node-smooth": "0.1.2" } } -] \ No newline at end of file +] diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index 52e2c6c..e9efb91 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -1346,11 +1346,12 @@ "9eaf5b01ad50102c", "9eaf5b01ad50102d", "9eaf5b01ad50102e", - "9eaf5b01ad50102f" + "9eaf5b01ad50102f", + "9eaf5b01ad501030" ], "x": 34, "y": 999, - "w": 1642, + "w": 1862, "h": 82 }, { @@ -6168,7 +6169,7 @@ "type": "function", "z": "ba537a53f567a2b3", "name": "Mode selection", - "func": "/* Mode selection \n * \n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * \n * Peak shaving will persist while:\n * - Grid power exceeds the import/export limit set by the user\n * - Batteries are applied to keep system below the limit set by the user\n * \n * The first is checked in this node.\n * The latter is checked in the last function node of the Peak Shaving flow.\n * Both set the `last_power_limit_violation` flow variable to Date.now() to persist peak shaving.\n * \n * PEAK_SHAVING_TIMEOUT_SEC is advised to be kept above atleast 3-5 seconds, to account for rate limiters and other power saving options.\n*/\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\nlet powerLimit = 0;\n\n// Input data | grid\nconst P1_power = parseInt(RED.util.getMessageProperty(msg, \"grid_power\") || 0);\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_import\") || 0);\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = parseInt(RED.util.getMessageProperty(msg, \"grid_power_limit_export\") || 0);\n\n\n// ACTIVATE shaving\nif (hasImportLimit && (P1_power > importLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n // powerLimit = importLimit;\n}\nif (hasExportLimit && (P1_power < exportLimit)) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n // powerLimit = exportLimit;\n}\n\n// Export or Import\nRED.util.setMessageProperty(msg, \"strategy.peak_direction\", P1_power > 0 ? \"import\":\"export\", true);\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = P1_power > 0 ? importLimit : exportLimit;\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation ? `Peak Shaving` :`Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving); \nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", + "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\nif (hasImportLimit && (aggregateImportRequired > 0 || importPhaseRequired > 0)) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (hasExportLimit && exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6218,7 +6219,7 @@ "z": "ba537a53f567a2b3", "g": "fec7c0b8c03be378", "name": "Peak shaving", - "func": "// Logger\nconst log = global.get(\"logger\");\n\n// Explain\nlog(this,`**Peak shaving mode** postponing '${msg.target}' strategy`);\nnode.status({ fill: \"green\", shape: \"dot\", text: `postponing '${msg.target}' strategy`});\n\n// Original target\nconst ORIGINAL_TARGET = msg.target;\n\n// Use the PID controller strategy\nmsg.target = \"Self-consumption\";\n// Set the grid power limit\nmsg.house_target_grid_consumption_in_w = msg.payload;\n\n// Type of peak shave\nconst peakDirection = RED.util.getMessageProperty(msg, \"strategy.peak_direction\");\nconst DISABLED = true;\n\nswitch (peakDirection) {\n case \"import\":\n // Ask PID to help shave import peak\n RED.util.setMessageProperty(msg, \"advanced_settings.charge_disabled\", DISABLED, true); \n break;\n case \"export\":\n // Ask PID to help shave export peak\n RED.util.setMessageProperty(msg, \"advanced_settings.discharge_disabled\", DISABLED, true);\n break;\n default:\n log(this, `Unknow peak type ${peakDirection}, peak shave failed`, \"warn\"); \n node.status({ fill: \"red\", shape: \"dot\", text: `peak type ${peakDirection}, peak shave failed`});\n // Set a safe value\n msg.house_target_grid_consumption_in_w = 0;\n}\n\nreturn msg;", + "func": "// Logger\nconst log = global.get(\"logger\");\n\n// Explain\nlog(this,`**Peak shaving mode** postponing '${msg.target}' strategy`);\nnode.status({ fill: \"green\", shape: \"dot\", text: `postponing '${msg.target}' strategy`});\n\n// Original target\nconst ORIGINAL_TARGET = msg.target;\n\n// Use the PID controller strategy\nmsg.target = \"Self-consumption\";\n// Set the grid power limit\nmsg.house_target_grid_consumption_in_w = msg.payload;\n\n// Type of peak shave\nconst peakDirection = RED.util.getMessageProperty(msg, \"strategy.peak_direction\");\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst DISABLED = true;\n\nif (phaseProtection.active) {\n RED.util.setMessageProperty(msg, \"advanced_settings.phase_protection\", {\n direction: phaseProtection.direction,\n active_phases: phaseProtection.active_phases || [],\n required_by_phase: phaseProtection.required_by_phase || {},\n required_phase_power: phaseProtection.required_phase_power || 0,\n aggregate_residual_power: phaseProtection.aggregate_residual_power || 0,\n required_total_power: phaseProtection.required_total_power || 0,\n }, true);\n}\n\nswitch (peakDirection) {\n case \"import\":\n // Ask PID to help shave import peak\n RED.util.setMessageProperty(msg, \"advanced_settings.charge_disabled\", DISABLED, true);\n break;\n case \"export\":\n // Ask PID to help shave export peak\n RED.util.setMessageProperty(msg, \"advanced_settings.discharge_disabled\", DISABLED, true);\n break;\n default:\n log(this, `Unknow peak type ${peakDirection}, peak shave failed`, \"warn\");\n node.status({ fill: \"red\", shape: \"dot\", text: `peak type ${peakDirection}, peak shave failed`});\n // Set a safe value\n msg.house_target_grid_consumption_in_w = 0;\n}\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6256,7 +6257,7 @@ "z": "ba537a53f567a2b3", "g": "fec7c0b8c03be378", "name": "Peak check", - "func": "// Logger\nconst log = global.get(\"logger\");\n\n// INPUT\nconst PID_assigned = msg.pid?.load_assigned ?? 0; // calculated battery power delivery, see footnote_1 why this is 0 by default\nconst isCharging = msg.batteries_charging || false;\n\n// HOLD peak shave when:\nif(PID_assigned > 0 && !isCharging) {\n // The batteries are still required to reduce the power peak\n log(this,`**Peak shaving continues**, batteries still required for peak reduction`);\n // Reset timeout timer\n flow.set(\"last_power_limit_violation\", Date.now()); \n // UI\n node.status({fill:\"yellow\",shape:\"dot\",text:`Holding peak shave, batteries still required for ${PID_assigned} W`});\n} else {\n // No solution or batteries not required - let timer run out\n node.status({fill:\"green\",shape:\"dot\",text:`Relax`});\n}\n\nreturn msg;\n\n// FOOTNOTE_1: \n// Self-consumption won't return `msg.solutions` nor `msg.pid.load_assigned` when:\n// - P1 is near or at it's target grid consumption (aka inside deadband)\n// - when a non-blocking error occurs\n// \n// This code does not specifically account for these cases, but applies a TIMEOUT mechanism instead.\n// As PID_assigned is kept 0, the timer won't be updated and runs out.", + "func": "// Logger\nconst log = global.get(\"logger\");\n\n// INPUT\nconst PID_assigned = msg.pid?.load_assigned ?? 0; // calculated battery power delivery, see footnote_1 why this is 0 by default\nconst isCharging = msg.batteries_charging || false;\nconst phaseProtectionActive = RED.util.getMessageProperty(msg, \"phase_protection.active\") === true;\n\n// HOLD peak shave when:\nif(PID_assigned > 0 && (!isCharging || phaseProtectionActive)) {\n // The batteries are still required to reduce the power peak\n log(this,`**Peak shaving continues**, batteries still required for peak reduction`);\n // Reset timeout timer\n flow.set(\"last_power_limit_violation\", Date.now());\n // UI\n node.status({fill:\"yellow\",shape:\"dot\",text:`Holding peak shave, batteries still required for ${PID_assigned} W`});\n} else {\n // No solution or batteries not required - let timer run out\n node.status({fill:\"green\",shape:\"dot\",text:`Relax`});\n}\n\nreturn msg;\n\n// FOOTNOTE_1:\n// Self-consumption won't return `msg.solutions` nor `msg.pid.load_assigned` when:\n// - P1 is near or at it's target grid consumption (aka inside deadband)\n// - when a non-blocking error occurs\n//\n// This code does not specifically account for these cases, but applies a TIMEOUT mechanism instead.\n// As PID_assigned is kept 0, the timer won't be updated and runs out.", "outputs": 1, "timeout": 0, "noerr": 0, @@ -13181,7 +13182,7 @@ "z": "489c548637b42621", "g": "ae121802b3fb6203", "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;", + "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// Phase protection is a safety-oriented peak-shave path. Do not let output\n// dampening reduce the correction below the immediate phase requirement.\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseRequired = Number(phaseProtection.required_total_power) || 0;\nif (phaseProtection.active === true && phaseRequired > 0) {\n if (phaseProtection.direction === \"import\" && PID_filtered > -phaseRequired) {\n PID_filtered = -phaseRequired;\n } else if (phaseProtection.direction === \"export\" && PID_filtered < phaseRequired) {\n PID_filtered = phaseRequired;\n }\n}\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, @@ -13245,7 +13246,7 @@ "z": "489c548637b42621", "g": "0d27c0ba873a6bdd", "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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? 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?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nvar unassigned_power = unassigned_power_initial; // Before distribution\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 >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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) {\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// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true); \nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(unassigned_power_initial - unassigned_power), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassigned_power,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n batteries_total_assignable_power += Number(assignablePower);\n\n return {\n battery,\n phase: getPhase(battery),\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nfunction assignPower(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n return assignedPower;\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n [\"L1\", \"L2\", \"L3\"].forEach((phase) => {\n let phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n batteryStates\n .filter((state) => state.phase === phase)\n .forEach((state) => {\n const assignedPower = assignPower(state, phaseRequiredPower);\n phaseRequiredPower -= assignedPower;\n phaseAssignment[phase].assigned += assignedPower;\n });\n\n phaseAssignment[phase].unassigned = phaseRequiredPower;\n if (phaseRequiredPower > 0) {\n log(this, `Phase protection ${phase}: ${phaseRequiredPower} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nbatteryStates.forEach((state) => {\n if (aggregatePowerToAssign <= 0) return;\n const assignedPower = assignPower(state, aggregatePowerToAssign);\n aggregatePowerToAssign -= assignedPower;\n aggregateAssignedPower += assignedPower;\n});\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, @@ -16162,6 +16163,57 @@ "libs": [], "x": 1510, "y": 1040, + "wires": [ + [ + "9eaf5b01ad501030" + ] + ] + }, + { + "id": "9eaf5b01ad501030", + "type": "api-current-state", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Phase protection?", + "server": "176d29a.6f648d6", + "version": 3, + "outputs": 1, + "halt_if": "", + "halt_if_type": "str", + "halt_if_compare": "is", + "entity_id": "input_boolean.house_battery_control_has_phase_protection", + "state_type": "str", + "blockInputOverrides": true, + "outputProperties": [ + { + "property": "payload", + "propertyType": "msg", + "value": "string", + "valueType": "entityState" + }, + { + "property": "data", + "propertyType": "msg", + "value": "", + "valueType": "entity" + }, + { + "property": "phase_protection.enabled", + "propertyType": "msg", + "value": "$entity().state = \"on\"", + "valueType": "jsonata" + } + ], + "for": "0", + "forType": "num", + "forUnits": "minutes", + "override_topic": false, + "state_location": "payload", + "override_payload": "msg", + "entity_location": "data", + "override_data": "msg", + "x": 1730, + "y": 1040, "wires": [ [ "2c2ed88ff158d0b7" From 8e81b230e2c98a6227c941b9db29994e0a27f3a9 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Sun, 7 Jun 2026 13:50:13 +0200 Subject: [PATCH 06/14] Apply phase protection before strategies --- docs/06-advanced-features.md | 2 +- node-red/01 start-flow.json | 26 ++++++++++++++++++++++++-- node-red/02 strategy-partials.json | 2 +- node-red/all-flows-in-one-file.json | 28 +++++++++++++++++++++++++--- 4 files changed, 51 insertions(+), 7 deletions(-) diff --git a/docs/06-advanced-features.md b/docs/06-advanced-features.md index 9a8f5fb..c379112 100644 --- a/docs/06-advanced-features.md +++ b/docs/06-advanced-features.md @@ -101,7 +101,7 @@ Controls grid import/export thresholds for `peak shaving` functionality. - **Import limit:** Maximum power to draw from the grid (example: 16A × 230V = 3680W for CAPTAR contracts) - **Export limit:** Maximum power to feed back to the grid (example: 3000W if grid connection has export limits) - **Max phase power:** Shared per-phase safety threshold. This value is exposed to Node-RED as `msg.grid_power_limit_phase` and is used by per-phase peak shaving when enabled. -- **Per-phase peak shaving:** Optional protection that uses configured L1/L2/L3 grid power sensors and battery phase assignments to reduce an overloaded phase. Missing phase sensors, unassigned batteries, or the feature being disabled keep the existing aggregate-only behavior. Unavailable batteries are treated as 0W assignable capacity, so remaining batteries on the same phase are asked to carry the correction. +- **Per-phase peak shaving:** Optional protection that uses configured L1/L2/L3 grid power sensors and battery phase assignments to reduce an overloaded phase before any strategy except Full stop is executed. Phase meter aliases must report power in watts; current-only sensors must be converted in `house_battery_control_config.yaml`. Missing phase sensors, unassigned batteries, or the feature being disabled keep the existing aggregate-only behavior. Unavailable batteries are treated as 0W assignable capacity, so remaining batteries on the same phase are asked to carry the correction. - **Configuration:** Adjust from the "Settings" tab in the Home Assistant dashboard ### Charge / Sell Power Mode diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index d685784..ca09204 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -42,7 +42,8 @@ "e0172f5ef06fa881", "10aa27fe189fa07e", "f7e09807696115eb", - "15830b3ade1b15be" + "15830b3ade1b15be", + "9eaf5b01ad501031" ], "x": 34, "y": 1099, @@ -572,7 +573,7 @@ "wires": [ [ "415a53493b19f461", - "a18697cfe0c15963" + "9eaf5b01ad501031" ] ] }, @@ -3749,5 +3750,26 @@ "f7e09807696115eb" ] ] + }, + { + "id": "9eaf5b01ad501031", + "type": "function", + "z": "cf69560481408644", + "g": "6373da5467ce39eb", + "name": "Phase protection guard", + "func": "/* Global phase-protection guard\n *\n * Selected strategies like Charge, Sell, and Dynamic 2 sub-strategies can\n * produce direct battery setpoints without passing through the peak-shaving\n * partial. Per-phase protection must be able to preempt them, except Full stop.\n */\nconst log = global.get(\"logger\");\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\nconst originalTarget = String(msg.target || \"\");\nif (originalTarget === \"Full stop\") {\n return msg;\n}\n\nconst phaseProtectionEnabled = RED.util.getMessageProperty(msg, \"phase_protection.enabled\") === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && assignedPhases.size > 0;\n\nfunction getPhaseRequirements(direction) {\n if (!phaseProtectionAvailable) {\n return { requiredByPhase: { L1: 0, L2: 0, L3: 0 }, activePhases: [] };\n }\n\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess > 0 && assignedPhases.has(phase)) {\n requiredByPhase[phase] = Math.ceil(excess);\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n };\n}\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nif (importRequired > 0 || exportRequired > 0) {\n // Import has priority because it protects loads drawing through the phase breaker.\n const direction = importRequired > 0 ? \"import\" : \"export\";\n const activePhases = importRequired > 0 ? importPhase.activePhases : exportPhase.activePhases;\n const requiredPower = importRequired > 0 ? importRequired : exportRequired;\n\n RED.util.setMessageProperty(msg, \"phase_protection.preempted_strategy\", originalTarget, true);\n msg.target = \"Standby / peak shave\";\n\n log(this, `**Per-phase peak protection** overrides ${originalTarget}: ${direction} ${activePhases.join(\", \")} requires ${requiredPower} W`);\n node.status({ fill: \"yellow\", shape: \"dot\", text: `Phase peak -> Standby / peak shave` });\n}\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 720, + "y": 1100, + "wires": [ + [ + "a18697cfe0c15963" + ] + ] } ] diff --git a/node-red/02 strategy-partials.json b/node-red/02 strategy-partials.json index 265c602..b457c19 100644 --- a/node-red/02 strategy-partials.json +++ b/node-red/02 strategy-partials.json @@ -341,7 +341,7 @@ "type": "function", "z": "1ae53b821b8673a2", "name": "Mode selection", - "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\nif (hasImportLimit && (aggregateImportRequired > 0 || importPhaseRequired > 0)) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (hasExportLimit && exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", + "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\n// Aggregate import/export violations still depend on their own toggles, but\n// phase-only violations are controlled by the per-phase protection toggle.\nif (aggregateImportRequired > 0 || importPhaseRequired > 0) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index e9efb91..d0453e8 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -1067,7 +1067,8 @@ "de7b9c6ffbec0e2b", "3594575e48abcb1f", "2c2ed88ff158d0b7", - "652b1cc2d8a8af36" + "652b1cc2d8a8af36", + "9eaf5b01ad501031" ], "x": 34, "y": 1099, @@ -3006,7 +3007,7 @@ "wires": [ [ "641a1632b399f7a6", - "78d74d259961a7ef" + "9eaf5b01ad501031" ] ] }, @@ -6169,7 +6170,7 @@ "type": "function", "z": "ba537a53f567a2b3", "name": "Mode selection", - "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\nif (hasImportLimit && (aggregateImportRequired > 0 || importPhaseRequired > 0)) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (hasExportLimit && exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", + "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\n// Aggregate import/export violations still depend on their own toggles, but\n// phase-only violations are controlled by the per-phase protection toggle.\nif (aggregateImportRequired > 0 || importPhaseRequired > 0) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -16219,5 +16220,26 @@ "2c2ed88ff158d0b7" ] ] + }, + { + "id": "9eaf5b01ad501031", + "type": "function", + "z": "419f395a5f52024b", + "g": "5e663f791c4382f7", + "name": "Phase protection guard", + "func": "/* Global phase-protection guard\n *\n * Selected strategies like Charge, Sell, and Dynamic 2 sub-strategies can\n * produce direct battery setpoints without passing through the peak-shaving\n * partial. Per-phase protection must be able to preempt them, except Full stop.\n */\nconst log = global.get(\"logger\");\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\nconst originalTarget = String(msg.target || \"\");\nif (originalTarget === \"Full stop\") {\n return msg;\n}\n\nconst phaseProtectionEnabled = RED.util.getMessageProperty(msg, \"phase_protection.enabled\") === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && assignedPhases.size > 0;\n\nfunction getPhaseRequirements(direction) {\n if (!phaseProtectionAvailable) {\n return { requiredByPhase: { L1: 0, L2: 0, L3: 0 }, activePhases: [] };\n }\n\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess > 0 && assignedPhases.has(phase)) {\n requiredByPhase[phase] = Math.ceil(excess);\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n };\n}\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nif (importRequired > 0 || exportRequired > 0) {\n // Import has priority because it protects loads drawing through the phase breaker.\n const direction = importRequired > 0 ? \"import\" : \"export\";\n const activePhases = importRequired > 0 ? importPhase.activePhases : exportPhase.activePhases;\n const requiredPower = importRequired > 0 ? importRequired : exportRequired;\n\n RED.util.setMessageProperty(msg, \"phase_protection.preempted_strategy\", originalTarget, true);\n msg.target = \"Standby / peak shave\";\n\n log(this, `**Per-phase peak protection** overrides ${originalTarget}: ${direction} ${activePhases.join(\", \")} requires ${requiredPower} W`);\n node.status({ fill: \"yellow\", shape: \"dot\", text: `Phase peak -> Standby / peak shave` });\n}\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 720, + "y": 1100, + "wires": [ + [ + "78d74d259961a7ef" + ] + ] } ] From da9c3131c3de64559f3948d29ce00afc585f6dc8 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Sun, 7 Jun 2026 14:33:46 +0200 Subject: [PATCH 07/14] Stabilize phase command limits --- docs/06-advanced-features.md | 2 +- home assistant/dashboard.yaml | 13 ++++++-- node-red/01 start-flow.json | 30 ++++++++++++++--- node-red/02 strategy-charge.json | 4 +-- node-red/02 strategy-partials.json | 2 +- node-red/02 strategy-self-consumption.json | 2 +- node-red/02 strategy-sell.json | 4 +-- node-red/all-flows-in-one-file.json | 38 +++++++++++++++++----- 8 files changed, 73 insertions(+), 22 deletions(-) diff --git a/docs/06-advanced-features.md b/docs/06-advanced-features.md index c379112..12b5a7c 100644 --- a/docs/06-advanced-features.md +++ b/docs/06-advanced-features.md @@ -101,7 +101,7 @@ Controls grid import/export thresholds for `peak shaving` functionality. - **Import limit:** Maximum power to draw from the grid (example: 16A × 230V = 3680W for CAPTAR contracts) - **Export limit:** Maximum power to feed back to the grid (example: 3000W if grid connection has export limits) - **Max phase power:** Shared per-phase safety threshold. This value is exposed to Node-RED as `msg.grid_power_limit_phase` and is used by per-phase peak shaving when enabled. -- **Per-phase peak shaving:** Optional protection that uses configured L1/L2/L3 grid power sensors and battery phase assignments to reduce an overloaded phase before any strategy except Full stop is executed. Phase meter aliases must report power in watts; current-only sensors must be converted in `house_battery_control_config.yaml`. Missing phase sensors, unassigned batteries, or the feature being disabled keep the existing aggregate-only behavior. Unavailable batteries are treated as 0W assignable capacity, so remaining batteries on the same phase are asked to carry the correction. +- **Per-phase peak shaving:** Optional protection that uses configured L1/L2/L3 grid power sensors and battery phase assignments. Charge, Sell, Charge PV, Self-consumption, and Dynamic strategies that select them cap new battery commands against available phase headroom first; if limiting battery interaction is not enough, peak shaving reduces the overloaded phase before any strategy except Full stop is executed. Phase meter aliases must report power in watts; current-only sensors must be converted in `house_battery_control_config.yaml`. Missing phase sensors, unassigned batteries, or the feature being disabled keep the existing aggregate-only behavior. Unavailable batteries are treated as 0W assignable capacity, so remaining batteries on the same phase are asked to carry the correction. - **Configuration:** Adjust from the "Settings" tab in the Home Assistant dashboard ### Charge / Sell Power Mode diff --git a/home assistant/dashboard.yaml b/home assistant/dashboard.yaml index 08084f8..45c4db5 100644 --- a/home assistant/dashboard.yaml +++ b/home assistant/dashboard.yaml @@ -1882,9 +1882,16 @@ views: name: Max phase power - type: markdown content: >- - When enabled, peak shaving also considers - configured L1/L2/L3 grid power sensors and phase-assigned - batteries. Leave disabled for aggregate-only behavior. + When enabled, configured L1/L2/L3 grid power + sensors and phase-assigned batteries are used for per-phase + protection. (Dis)charge throttling is applied by + Self-consumption, Charge PV, Charge, Sell, and Dynamic/Dynamic 2 + whenever they select those strategies. Forced phase relief + applies before every selected strategy except Full stop when the + phase overload is not caused by battery interaction. Custom + strategies that write battery commands directly must opt into the + shared phase command limits themselves. Leave disabled for + aggregate-only behavior. text_only: true grid_options: columns: full diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index ca09204..6d0d172 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -323,11 +323,12 @@ "9eaf5b01ad50102d", "9eaf5b01ad50102e", "9eaf5b01ad50102f", - "9eaf5b01ad501030" + "9eaf5b01ad501030", + "9eaf5b01ad501032" ], "x": 34, "y": 999, - "w": 1862, + "w": 2106, "h": 82 }, { @@ -3747,7 +3748,7 @@ "y": 1040, "wires": [ [ - "f7e09807696115eb" + "9eaf5b01ad501032" ] ] }, @@ -3757,7 +3758,7 @@ "z": "cf69560481408644", "g": "6373da5467ce39eb", "name": "Phase protection guard", - "func": "/* Global phase-protection guard\n *\n * Selected strategies like Charge, Sell, and Dynamic 2 sub-strategies can\n * produce direct battery setpoints without passing through the peak-shaving\n * partial. Per-phase protection must be able to preempt them, except Full stop.\n */\nconst log = global.get(\"logger\");\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\nconst originalTarget = String(msg.target || \"\");\nif (originalTarget === \"Full stop\") {\n return msg;\n}\n\nconst phaseProtectionEnabled = RED.util.getMessageProperty(msg, \"phase_protection.enabled\") === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && assignedPhases.size > 0;\n\nfunction getPhaseRequirements(direction) {\n if (!phaseProtectionAvailable) {\n return { requiredByPhase: { L1: 0, L2: 0, L3: 0 }, activePhases: [] };\n }\n\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess > 0 && assignedPhases.has(phase)) {\n requiredByPhase[phase] = Math.ceil(excess);\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n };\n}\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nif (importRequired > 0 || exportRequired > 0) {\n // Import has priority because it protects loads drawing through the phase breaker.\n const direction = importRequired > 0 ? \"import\" : \"export\";\n const activePhases = importRequired > 0 ? importPhase.activePhases : exportPhase.activePhases;\n const requiredPower = importRequired > 0 ? importRequired : exportRequired;\n\n RED.util.setMessageProperty(msg, \"phase_protection.preempted_strategy\", originalTarget, true);\n msg.target = \"Standby / peak shave\";\n\n log(this, `**Per-phase peak protection** overrides ${originalTarget}: ${direction} ${activePhases.join(\", \")} requires ${requiredPower} W`);\n node.status({ fill: \"yellow\", shape: \"dot\", text: `Phase peak -> Standby / peak shave` });\n}\n\nreturn msg;", + "func": "/* Global phase-protection guard\n *\n * Selected strategies like Charge, Sell, and Dynamic 2 sub-strategies can\n * produce direct battery setpoints without passing through the peak-shaving\n * partial. Per-phase protection must be able to preempt them, except Full stop.\n */\nconst log = global.get(\"logger\");\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\nconst originalTarget = String(msg.target || \"\");\nif (originalTarget === \"Full stop\") {\n return msg;\n}\n\nconst phaseProtectionEnabled = RED.util.getMessageProperty(msg, \"phase_protection.enabled\") === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseBatteryPower = RED.util.getMessageProperty(msg, \"phase_protection.phase_battery_power\") || {};\nconst phaseBatteryChargePower = phaseBatteryPower.charge || {};\nconst phaseBatteryDischargePower = phaseBatteryPower.discharge || {};\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && assignedPhases.size > 0;\n\nfunction getPhaseRequirements(direction) {\n if (!phaseProtectionAvailable) {\n return { requiredByPhase: { L1: 0, L2: 0, L3: 0 }, activePhases: [] };\n }\n\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const currentChargePower = direction === \"import\"\n ? (toNumberOrNull(phaseBatteryChargePower[phase]) ?? 0)\n : 0;\n const currentDischargePower = direction === \"export\"\n ? (toNumberOrNull(phaseBatteryDischargePower[phase]) ?? 0)\n : 0;\n const excess = direction === \"import\"\n ? reading - currentChargePower - phaseLimit\n : (reading * -1) - currentDischargePower - phaseLimit;\n\n if (excess > 0 && assignedPhases.has(phase)) {\n requiredByPhase[phase] = Math.ceil(excess);\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n };\n}\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nif (importRequired > 0 || exportRequired > 0) {\n // Import has priority because it protects loads drawing through the phase breaker.\n const direction = importRequired > 0 ? \"import\" : \"export\";\n const activePhases = importRequired > 0 ? importPhase.activePhases : exportPhase.activePhases;\n const requiredPower = importRequired > 0 ? importRequired : exportRequired;\n\n RED.util.setMessageProperty(msg, \"phase_protection.preempted_strategy\", originalTarget, true);\n msg.target = \"Standby / peak shave\";\n\n log(this, `**Per-phase peak protection** overrides ${originalTarget}: ${direction} ${activePhases.join(\", \")} requires ${requiredPower} W`);\n node.status({ fill: \"yellow\", shape: \"dot\", text: `Phase peak -> Standby / peak shave` });\n}\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -3771,5 +3772,26 @@ "a18697cfe0c15963" ] ] + }, + { + "id": "9eaf5b01ad501032", + "type": "function", + "z": "cf69560481408644", + "g": "80405532eb3f52d4", + "name": "Phase command limits", + "func": "const PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\n\nconst phaseProtectionEnabled = RED.util.getMessageProperty(msg, \"phase_protection.enabled\") === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\n\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseBatteryPower = {\n signed: { L1: 0, L2: 0, L3: 0 },\n charge: { L1: 0, L2: 0, L3: 0 },\n discharge: { L1: 0, L2: 0, L3: 0 },\n};\n\nbatteries.forEach((battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n if (!PHASES.includes(phase)) return;\n\n const power = Number(battery.power);\n if (!Number.isFinite(power)) return;\n\n phaseBatteryPower.signed[phase] += power;\n if (power > 0) phaseBatteryPower.charge[phase] += power;\n if (power < 0) phaseBatteryPower.discharge[phase] += Math.abs(power);\n});\n\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst commandLimitsAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable;\nconst commandLimits = { charge: {}, discharge: {} };\n\nPHASES.forEach((phase) => {\n if (!commandLimitsAvailable) {\n commandLimits.charge[phase] = null;\n commandLimits.discharge[phase] = null;\n return;\n }\n\n // Remove the batteries' current signed contribution from the phase\n // reading so the next command is capped against the underlying phase load.\n const nonBatteryPhasePower = phaseReadings[phase] - phaseBatteryPower.signed[phase];\n commandLimits.charge[phase] = Math.max(0, Math.floor(phaseLimit - nonBatteryPhasePower));\n commandLimits.discharge[phase] = Math.max(0, Math.floor(nonBatteryPhasePower + phaseLimit));\n});\n\nRED.util.setMessageProperty(msg, \"phase_protection.command_limits_available\", commandLimitsAvailable, true);\nRED.util.setMessageProperty(msg, \"phase_protection.command_limit_by_phase\", commandLimits, true);\nRED.util.setMessageProperty(msg, \"phase_protection.phase_battery_power\", phaseBatteryPower, true);\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1960, + "y": 1040, + "wires": [ + [ + "f7e09807696115eb" + ] + ] } ] diff --git a/node-red/02 strategy-charge.json b/node-red/02 strategy-charge.json index adc69ad..36b3f8b 100644 --- a/node-red/02 strategy-charge.json +++ b/node-red/02 strategy-charge.json @@ -294,7 +294,7 @@ "z": "e31b0cf1ca8100ca", "g": "64710d0b7446b478", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = msg.batteries;\nlet solution_array = [];\n\n// build solution\nmsg.batteries.forEach(battery => {\n const calculatedPower = Number(battery.charging_max);\n\n solution_array.push({\n id: battery.id,\n mode: \"charge\",\n power: calculatedPower\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nlet solution_array = [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseChargeRemaining = { ...phaseChargeLimits };\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction takePhaseChargePower(phase, requestedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return requestedPower;\n\n const remaining = Number(phaseChargeRemaining[phase]);\n if (!Number.isFinite(remaining)) return requestedPower;\n\n const assignedPower = Math.min(requestedPower, Math.max(0, remaining));\n phaseChargeRemaining[phase] = Math.max(0, remaining - assignedPower);\n return assignedPower;\n}\n\n// build solution\nbatteries.forEach(battery => {\n const maxChargePower = Math.max(0, Number(battery.charging_max) || 0);\n const phase = getPhase(battery);\n const calculatedPower = takePhaseChargePower(phase, maxChargePower);\n\n if (calculatedPower < maxChargePower) {\n log(this, `Battery ${battery.id}: phase ${phase} charge limit ${maxChargePower}W -> ${calculatedPower}W`);\n }\n\n solution_array.push({\n id: battery.id,\n mode: calculatedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(calculatedPower)\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -1389,4 +1389,4 @@ "node-red-contrib-home-assistant-websocket": "0.80.3" } } -] \ No newline at end of file +] diff --git a/node-red/02 strategy-partials.json b/node-red/02 strategy-partials.json index b457c19..03617bc 100644 --- a/node-red/02 strategy-partials.json +++ b/node-red/02 strategy-partials.json @@ -341,7 +341,7 @@ "type": "function", "z": "1ae53b821b8673a2", "name": "Mode selection", - "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\n// Aggregate import/export violations still depend on their own toggles, but\n// phase-only violations are controlled by the per-phase protection toggle.\nif (aggregateImportRequired > 0 || importPhaseRequired > 0) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", + "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseBatteryPower = RED.util.getMessageProperty(msg, \"phase_protection.phase_battery_power\") || {};\nconst phaseBatteryChargePower = phaseBatteryPower.charge || {};\nconst phaseBatteryDischargePower = phaseBatteryPower.discharge || {};\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const currentChargePower = direction === \"import\"\n ? (toNumberOrNull(phaseBatteryChargePower[phase]) ?? 0)\n : 0;\n const currentDischargePower = direction === \"export\"\n ? (toNumberOrNull(phaseBatteryDischargePower[phase]) ?? 0)\n : 0;\n const excess = direction === \"import\"\n ? reading - currentChargePower - phaseLimit\n : (reading * -1) - currentDischargePower - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\n// Aggregate import/export violations still depend on their own toggles, but\n// phase-only violations are controlled by the per-phase protection toggle.\nif (aggregateImportRequired > 0 || importPhaseRequired > 0) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/02 strategy-self-consumption.json b/node-red/02 strategy-self-consumption.json index b102ecc..20bc145 100644 --- a/node-red/02 strategy-self-consumption.json +++ b/node-red/02 strategy-self-consumption.json @@ -1820,7 +1820,7 @@ "z": "08bd910806aaf74c", "g": "a53d59ea9a6ce336", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n batteries_total_assignable_power += Number(assignablePower);\n\n return {\n battery,\n phase: getPhase(battery),\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nfunction assignPower(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n return assignedPower;\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n [\"L1\", \"L2\", \"L3\"].forEach((phase) => {\n let phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n batteryStates\n .filter((state) => state.phase === phase)\n .forEach((state) => {\n const assignedPower = assignPower(state, phaseRequiredPower);\n phaseRequiredPower -= assignedPower;\n phaseAssignment[phase].assigned += assignedPower;\n });\n\n phaseAssignment[phase].unassigned = phaseRequiredPower;\n if (phaseRequiredPower > 0) {\n log(this, `Phase protection ${phase}: ${phaseRequiredPower} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nbatteryStates.forEach((state) => {\n if (aggregatePowerToAssign <= 0) return;\n const assignedPower = assignPower(state, aggregatePowerToAssign);\n aggregatePowerToAssign -= assignedPower;\n aggregateAssignedPower += assignedPower;\n});\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPower(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n [\"L1\", \"L2\", \"L3\"].forEach((phase) => {\n let phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n batteryStates\n .filter((state) => state.phase === phase)\n .forEach((state) => {\n const assignedPower = assignPower(state, phaseRequiredPower);\n phaseRequiredPower -= assignedPower;\n phaseAssignment[phase].assigned += assignedPower;\n });\n\n phaseAssignment[phase].unassigned = phaseRequiredPower;\n if (phaseRequiredPower > 0) {\n log(this, `Phase protection ${phase}: ${phaseRequiredPower} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nbatteryStates.forEach((state) => {\n if (aggregatePowerToAssign <= 0) return;\n const assignedPower = assignPower(state, aggregatePowerToAssign);\n aggregatePowerToAssign -= assignedPower;\n aggregateAssignedPower += assignedPower;\n});\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, diff --git a/node-red/02 strategy-sell.json b/node-red/02 strategy-sell.json index b94e138..80249d0 100644 --- a/node-red/02 strategy-sell.json +++ b/node-red/02 strategy-sell.json @@ -251,7 +251,7 @@ "z": "68716753bacc0887", "g": "827abf066017f642", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = msg.batteries;\nlet solution_array = [];\n\n// build solution\nmsg.batteries.forEach(battery => {\n const calculatedPower = Number(battery.discharging_max);\n\n solution_array.push({\n id: battery.id,\n mode: \"discharge\",\n power: calculatedPower\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nlet solution_array = [];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\nconst phaseDischargeRemaining = { ...phaseDischargeLimits };\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction takePhaseDischargePower(phase, requestedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return requestedPower;\n\n const remaining = Number(phaseDischargeRemaining[phase]);\n if (!Number.isFinite(remaining)) return requestedPower;\n\n const assignedPower = Math.min(requestedPower, Math.max(0, remaining));\n phaseDischargeRemaining[phase] = Math.max(0, remaining - assignedPower);\n return assignedPower;\n}\n\n// build solution\nbatteries.forEach(battery => {\n const maxDischargePower = Math.max(0, Number(battery.discharging_max) || 0);\n const phase = getPhase(battery);\n const calculatedPower = takePhaseDischargePower(phase, maxDischargePower);\n\n if (calculatedPower < maxDischargePower) {\n log(this, `Battery ${battery.id}: phase ${phase} discharge limit ${maxDischargePower}W -> ${calculatedPower}W`);\n }\n\n solution_array.push({\n id: battery.id,\n mode: calculatedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(calculatedPower)\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -1103,4 +1103,4 @@ "node-red-contrib-home-assistant-websocket": "0.80.3" } } -] \ No newline at end of file +] diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index d0453e8..54c0586 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -1348,11 +1348,12 @@ "9eaf5b01ad50102d", "9eaf5b01ad50102e", "9eaf5b01ad50102f", - "9eaf5b01ad501030" + "9eaf5b01ad501030", + "9eaf5b01ad501032" ], "x": 34, "y": 999, - "w": 1862, + "w": 2106, "h": 82 }, { @@ -6170,7 +6171,7 @@ "type": "function", "z": "ba537a53f567a2b3", "name": "Mode selection", - "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\n// Aggregate import/export violations still depend on their own toggles, but\n// phase-only violations are controlled by the per-phase protection toggle.\nif (aggregateImportRequired > 0 || importPhaseRequired > 0) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", + "func": "/* Mode selection\n *\n * Peak shaving can be triggered by:\n * - Grid power exceeds the import/export limit set by the user\n * - Optional per-phase power exceeds the configured phase limit\n *\n * Per-phase protection only runs when enabled, L1/L2/L3 sensors are readable,\n * and the overloaded phase has at least one assigned battery.\n */\n\n// Timestamp\nconst now = Date.now();\n\n// Logger\nconst log = global.get(\"logger\");\n\n// Configuration & State recovery\nconst PEAK_SHAVING_TIMEOUT_SEC = 10;\nlet isPeakShaving = flow.get(\"is_peak_shaving\") ?? false;\nlet lastPowerLimitViolation = flow.get(\"last_power_limit_violation\") ?? now;\nlet isPowerLimitViolation = false;\n\n// Helpers\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\n// Input data | grid\nconst P1_power = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power\")) ?? 0;\n// Input data | Charge PV\nconst hasImportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_import\") || false;\nconst importLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_import\")) ?? 0;\n// Input data | Sell PV\nconst hasExportLimit = RED.util.getMessageProperty(msg, \"grid_power_has_limit_export\") || false;\nconst exportLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_export\")) ?? 0;\n\n// Input data | phase protection\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionEnabled = phaseProtection.enabled === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseBatteryPower = RED.util.getMessageProperty(msg, \"phase_protection.phase_battery_power\") || {};\nconst phaseBatteryChargePower = phaseBatteryPower.charge || {};\nconst phaseBatteryDischargePower = phaseBatteryPower.discharge || {};\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseAssignmentsAvailable = assignedPhases.size > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && phaseAssignmentsAvailable;\n\nconst phaseState = {\n enabled: phaseProtectionEnabled,\n available: phaseProtectionAvailable,\n active: false,\n direction: null,\n limit: phaseLimitAvailable ? phaseLimit : null,\n sensors_available: phaseSensorsAvailable,\n assignments_available: phaseAssignmentsAvailable,\n assigned_phases: [...assignedPhases],\n active_phases: [],\n required_by_phase: { L1: 0, L2: 0, L3: 0 },\n required_phase_power: 0,\n aggregate_required_power: 0,\n aggregate_residual_power: 0,\n required_total_power: 0,\n target_grid_consumption_in_w: null,\n unassigned_violations: [],\n};\n\nfunction getPhaseRequirements(direction) {\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n const unassignedViolations = [];\n\n if (!phaseProtectionAvailable) {\n return { requiredByPhase, activePhases: [], unassignedViolations };\n }\n\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const currentChargePower = direction === \"import\"\n ? (toNumberOrNull(phaseBatteryChargePower[phase]) ?? 0)\n : 0;\n const currentDischargePower = direction === \"export\"\n ? (toNumberOrNull(phaseBatteryDischargePower[phase]) ?? 0)\n : 0;\n const excess = direction === \"import\"\n ? reading - currentChargePower - phaseLimit\n : (reading * -1) - currentDischargePower - phaseLimit;\n\n if (excess <= 0) return;\n\n const requiredPower = Math.ceil(excess);\n if (assignedPhases.has(phase)) {\n requiredByPhase[phase] = requiredPower;\n } else {\n unassignedViolations.push({ phase, direction, required_power: requiredPower, power: reading });\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n unassignedViolations,\n };\n}\n\nconst aggregateImportRequired = hasImportLimit && importLimit > 0\n ? Math.max(0, P1_power - importLimit)\n : 0;\n// Preserve the existing aggregate export activation behavior for backward compatibility.\nconst aggregateExportViolation = hasExportLimit && P1_power < exportLimit;\nconst aggregateExportRequired = hasExportLimit && exportLimit > 0\n ? Math.max(0, (P1_power * -1) - exportLimit)\n : 0;\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importPhaseRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportPhaseRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nlet selectedDirection = null;\nlet selectedTarget = null;\nlet selectedPhase = null;\nlet selectedAggregateRequired = 0;\n\n// Import protection is prioritized when there is a conflict because it protects loads drawing from the grid.\n// Aggregate import/export violations still depend on their own toggles, but\n// phase-only violations are controlled by the per-phase protection toggle.\nif (aggregateImportRequired > 0 || importPhaseRequired > 0) {\n selectedDirection = \"import\";\n selectedPhase = importPhase;\n selectedAggregateRequired = aggregateImportRequired;\n const requiredTotal = Math.max(aggregateImportRequired, importPhaseRequired);\n selectedTarget = P1_power - requiredTotal;\n} else if (exportPhaseRequired > 0) {\n selectedDirection = \"export\";\n selectedPhase = exportPhase;\n selectedAggregateRequired = aggregateExportRequired;\n const requiredTotal = Math.max(aggregateExportRequired, exportPhaseRequired);\n selectedTarget = P1_power + requiredTotal;\n} else if (aggregateExportViolation) {\n selectedDirection = P1_power > 0 ? \"import\" : \"export\";\n selectedTarget = P1_power > 0 ? importLimit : exportLimit;\n}\n\nif (selectedDirection !== null) {\n isPeakShaving = true;\n isPowerLimitViolation = true;\n}\n\nif (selectedPhase && sumValues(Object.values(selectedPhase.requiredByPhase)) > 0) {\n const phaseRequired = sumValues(Object.values(selectedPhase.requiredByPhase));\n const requiredTotal = Math.max(selectedAggregateRequired, phaseRequired);\n\n phaseState.active = true;\n phaseState.direction = selectedDirection;\n phaseState.active_phases = selectedPhase.activePhases;\n phaseState.required_by_phase = selectedPhase.requiredByPhase;\n phaseState.required_phase_power = phaseRequired;\n phaseState.aggregate_required_power = selectedAggregateRequired;\n phaseState.aggregate_residual_power = Math.max(0, requiredTotal - phaseRequired);\n phaseState.required_total_power = requiredTotal;\n phaseState.target_grid_consumption_in_w = selectedTarget;\n phaseState.unassigned_violations = selectedPhase.unassignedViolations;\n\n log(this, `**Per-phase peak shaving** ${selectedDirection}: ${phaseState.active_phases.join(\", \")} requires ${phaseRequired} W`);\n}\n\nif (phaseProtectionAvailable && importPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase import violation without assigned battery: ${JSON.stringify(importPhase.unassignedViolations)}`, \"warn\");\n}\nif (phaseProtectionAvailable && exportPhase.unassignedViolations.length > 0) {\n log(this, `Per-phase export violation without assigned battery: ${JSON.stringify(exportPhase.unassignedViolations)}`, \"warn\");\n}\n\n// Export or Import\nif (selectedDirection !== null) {\n RED.util.setMessageProperty(msg, \"strategy.peak_direction\", selectedDirection, true);\n}\n\n// UPDATE timer or continue\nif (isPowerLimitViolation) lastPowerLimitViolation = now;\n\n// CALCULATE release logic\n// Seconds since the last time the power limit was exceeded\nlet secondsSinceLastViolation = Math.floor((now - lastPowerLimitViolation) / 1000);\n\n// RELEASE shaving logic\n// Only release if we are currently shaving AND the timeout has passed\nif (isPeakShaving && (secondsSinceLastViolation >= PEAK_SHAVING_TIMEOUT_SEC)) {\n isPeakShaving = false;\n log(this,\"Peak Shaving released, resume normal operation\");\n}\n\n// UI & OUTPUT Logic\nif (isPeakShaving) {\n // Timeout progress\n const secondsRemaining = PEAK_SHAVING_TIMEOUT_SEC - secondsSinceLastViolation;\n // Set grid power limit\n msg.payload = selectedTarget ?? (P1_power > 0 ? importLimit : exportLimit);\n // UI\n node.status({\n fill: \"yellow\",\n shape: \"dot\",\n text: isPowerLimitViolation\n ? (phaseState.active ? `Phase peak shaving` : `Peak Shaving`)\n : `Peak Shaving: release in ${secondsRemaining}s`\n });\n} else {\n // UI\n node.status({\n fill: \"green\",\n shape: \"dot\",\n text: `Grid OK - ${msg.target}`\n });\n}\n\n// Persist state for next run\nflow.set(\"is_peak_shaving\", isPeakShaving);\nif (isPowerLimitViolation) flow.set(\"last_power_limit_violation\", now); // Power limit exceeded. Update the lastPowerLimitViolation timestamp to current time\n\n// Finalize msg\nRED.util.setMessageProperty(msg, \"phase_protection\", phaseState, true);\nRED.util.setMessageProperty(msg, \"strategy.is_peak_shaving\", isPeakShaving, true);\n\n// OUTPUT\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6615,7 +6616,7 @@ "z": "ab98da23bbbad975", "g": "7851e6c9bd5ff6f6", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = msg.batteries;\nlet solution_array = [];\n\n// build solution\nmsg.batteries.forEach(battery => {\n const calculatedPower = Number(battery.charging_max);\n\n solution_array.push({\n id: battery.id,\n mode: \"charge\",\n power: calculatedPower\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nlet solution_array = [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseChargeRemaining = { ...phaseChargeLimits };\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction takePhaseChargePower(phase, requestedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return requestedPower;\n\n const remaining = Number(phaseChargeRemaining[phase]);\n if (!Number.isFinite(remaining)) return requestedPower;\n\n const assignedPower = Math.min(requestedPower, Math.max(0, remaining));\n phaseChargeRemaining[phase] = Math.max(0, remaining - assignedPower);\n return assignedPower;\n}\n\n// build solution\nbatteries.forEach(battery => {\n const maxChargePower = Math.max(0, Number(battery.charging_max) || 0);\n const phase = getPhase(battery);\n const calculatedPower = takePhaseChargePower(phase, maxChargePower);\n\n if (calculatedPower < maxChargePower) {\n log(this, `Battery ${battery.id}: phase ${phase} charge limit ${maxChargePower}W -> ${calculatedPower}W`);\n }\n\n solution_array.push({\n id: battery.id,\n mode: calculatedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(calculatedPower)\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -13247,7 +13248,7 @@ "z": "489c548637b42621", "g": "0d27c0ba873a6bdd", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n batteries_total_assignable_power += Number(assignablePower);\n\n return {\n battery,\n phase: getPhase(battery),\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nfunction assignPower(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n return assignedPower;\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n [\"L1\", \"L2\", \"L3\"].forEach((phase) => {\n let phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n batteryStates\n .filter((state) => state.phase === phase)\n .forEach((state) => {\n const assignedPower = assignPower(state, phaseRequiredPower);\n phaseRequiredPower -= assignedPower;\n phaseAssignment[phase].assigned += assignedPower;\n });\n\n phaseAssignment[phase].unassigned = phaseRequiredPower;\n if (phaseRequiredPower > 0) {\n log(this, `Phase protection ${phase}: ${phaseRequiredPower} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nbatteryStates.forEach((state) => {\n if (aggregatePowerToAssign <= 0) return;\n const assignedPower = assignPower(state, aggregatePowerToAssign);\n aggregatePowerToAssign -= assignedPower;\n aggregateAssignedPower += assignedPower;\n});\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPower(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n [\"L1\", \"L2\", \"L3\"].forEach((phase) => {\n let phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n batteryStates\n .filter((state) => state.phase === phase)\n .forEach((state) => {\n const assignedPower = assignPower(state, phaseRequiredPower);\n phaseRequiredPower -= assignedPower;\n phaseAssignment[phase].assigned += assignedPower;\n });\n\n phaseAssignment[phase].unassigned = phaseRequiredPower;\n if (phaseRequiredPower > 0) {\n log(this, `Phase protection ${phase}: ${phaseRequiredPower} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nbatteryStates.forEach((state) => {\n if (aggregatePowerToAssign <= 0) return;\n const assignedPower = assignPower(state, aggregatePowerToAssign);\n aggregatePowerToAssign -= assignedPower;\n aggregateAssignedPower += assignedPower;\n});\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, @@ -13887,7 +13888,7 @@ "z": "9502ef431612a690", "g": "cda87dc2fda3f49a", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = msg.batteries;\nlet solution_array = [];\n\n// build solution\nmsg.batteries.forEach(battery => {\n const calculatedPower = Number(battery.discharging_max);\n\n solution_array.push({\n id: battery.id,\n mode: \"discharge\",\n power: calculatedPower\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nlet solution_array = [];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\nconst phaseDischargeRemaining = { ...phaseDischargeLimits };\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction takePhaseDischargePower(phase, requestedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return requestedPower;\n\n const remaining = Number(phaseDischargeRemaining[phase]);\n if (!Number.isFinite(remaining)) return requestedPower;\n\n const assignedPower = Math.min(requestedPower, Math.max(0, remaining));\n phaseDischargeRemaining[phase] = Math.max(0, remaining - assignedPower);\n return assignedPower;\n}\n\n// build solution\nbatteries.forEach(battery => {\n const maxDischargePower = Math.max(0, Number(battery.discharging_max) || 0);\n const phase = getPhase(battery);\n const calculatedPower = takePhaseDischargePower(phase, maxDischargePower);\n\n if (calculatedPower < maxDischargePower) {\n log(this, `Battery ${battery.id}: phase ${phase} discharge limit ${maxDischargePower}W -> ${calculatedPower}W`);\n }\n\n solution_array.push({\n id: battery.id,\n mode: calculatedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(calculatedPower)\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -16217,7 +16218,7 @@ "y": 1040, "wires": [ [ - "2c2ed88ff158d0b7" + "9eaf5b01ad501032" ] ] }, @@ -16227,7 +16228,7 @@ "z": "419f395a5f52024b", "g": "5e663f791c4382f7", "name": "Phase protection guard", - "func": "/* Global phase-protection guard\n *\n * Selected strategies like Charge, Sell, and Dynamic 2 sub-strategies can\n * produce direct battery setpoints without passing through the peak-shaving\n * partial. Per-phase protection must be able to preempt them, except Full stop.\n */\nconst log = global.get(\"logger\");\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\nconst originalTarget = String(msg.target || \"\");\nif (originalTarget === \"Full stop\") {\n return msg;\n}\n\nconst phaseProtectionEnabled = RED.util.getMessageProperty(msg, \"phase_protection.enabled\") === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && assignedPhases.size > 0;\n\nfunction getPhaseRequirements(direction) {\n if (!phaseProtectionAvailable) {\n return { requiredByPhase: { L1: 0, L2: 0, L3: 0 }, activePhases: [] };\n }\n\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const excess = direction === \"import\"\n ? reading - phaseLimit\n : (reading * -1) - phaseLimit;\n\n if (excess > 0 && assignedPhases.has(phase)) {\n requiredByPhase[phase] = Math.ceil(excess);\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n };\n}\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nif (importRequired > 0 || exportRequired > 0) {\n // Import has priority because it protects loads drawing through the phase breaker.\n const direction = importRequired > 0 ? \"import\" : \"export\";\n const activePhases = importRequired > 0 ? importPhase.activePhases : exportPhase.activePhases;\n const requiredPower = importRequired > 0 ? importRequired : exportRequired;\n\n RED.util.setMessageProperty(msg, \"phase_protection.preempted_strategy\", originalTarget, true);\n msg.target = \"Standby / peak shave\";\n\n log(this, `**Per-phase peak protection** overrides ${originalTarget}: ${direction} ${activePhases.join(\", \")} requires ${requiredPower} W`);\n node.status({ fill: \"yellow\", shape: \"dot\", text: `Phase peak -> Standby / peak shave` });\n}\n\nreturn msg;", + "func": "/* Global phase-protection guard\n *\n * Selected strategies like Charge, Sell, and Dynamic 2 sub-strategies can\n * produce direct battery setpoints without passing through the peak-shaving\n * partial. Per-phase protection must be able to preempt them, except Full stop.\n */\nconst log = global.get(\"logger\");\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\nconst sumValues = (values) => values.reduce((sum, value) => sum + value, 0);\n\nconst originalTarget = String(msg.target || \"\");\nif (originalTarget === \"Full stop\") {\n return msg;\n}\n\nconst phaseProtectionEnabled = RED.util.getMessageProperty(msg, \"phase_protection.enabled\") === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst assignedPhases = new Set(\n batteries\n .map((battery) => String(battery.phase || \"\").toUpperCase())\n .filter((phase) => PHASES.includes(phase))\n);\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseBatteryPower = RED.util.getMessageProperty(msg, \"phase_protection.phase_battery_power\") || {};\nconst phaseBatteryChargePower = phaseBatteryPower.charge || {};\nconst phaseBatteryDischargePower = phaseBatteryPower.discharge || {};\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst phaseProtectionAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable && assignedPhases.size > 0;\n\nfunction getPhaseRequirements(direction) {\n if (!phaseProtectionAvailable) {\n return { requiredByPhase: { L1: 0, L2: 0, L3: 0 }, activePhases: [] };\n }\n\n const requiredByPhase = { L1: 0, L2: 0, L3: 0 };\n PHASES.forEach((phase) => {\n const reading = phaseReadings[phase];\n const currentChargePower = direction === \"import\"\n ? (toNumberOrNull(phaseBatteryChargePower[phase]) ?? 0)\n : 0;\n const currentDischargePower = direction === \"export\"\n ? (toNumberOrNull(phaseBatteryDischargePower[phase]) ?? 0)\n : 0;\n const excess = direction === \"import\"\n ? reading - currentChargePower - phaseLimit\n : (reading * -1) - currentDischargePower - phaseLimit;\n\n if (excess > 0 && assignedPhases.has(phase)) {\n requiredByPhase[phase] = Math.ceil(excess);\n }\n });\n\n return {\n requiredByPhase,\n activePhases: PHASES.filter((phase) => requiredByPhase[phase] > 0),\n };\n}\n\nconst importPhase = getPhaseRequirements(\"import\");\nconst exportPhase = getPhaseRequirements(\"export\");\nconst importRequired = sumValues(Object.values(importPhase.requiredByPhase));\nconst exportRequired = sumValues(Object.values(exportPhase.requiredByPhase));\n\nif (importRequired > 0 || exportRequired > 0) {\n // Import has priority because it protects loads drawing through the phase breaker.\n const direction = importRequired > 0 ? \"import\" : \"export\";\n const activePhases = importRequired > 0 ? importPhase.activePhases : exportPhase.activePhases;\n const requiredPower = importRequired > 0 ? importRequired : exportRequired;\n\n RED.util.setMessageProperty(msg, \"phase_protection.preempted_strategy\", originalTarget, true);\n msg.target = \"Standby / peak shave\";\n\n log(this, `**Per-phase peak protection** overrides ${originalTarget}: ${direction} ${activePhases.join(\", \")} requires ${requiredPower} W`);\n node.status({ fill: \"yellow\", shape: \"dot\", text: `Phase peak -> Standby / peak shave` });\n}\n\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -16241,5 +16242,26 @@ "78d74d259961a7ef" ] ] + }, + { + "id": "9eaf5b01ad501032", + "type": "function", + "z": "419f395a5f52024b", + "g": "79e4b4f8fef66565", + "name": "Phase command limits", + "func": "const PHASES = [\"L1\", \"L2\", \"L3\"];\nconst toNumberOrNull = (value) => {\n const numericValue = Number(value);\n return Number.isFinite(numericValue) ? numericValue : null;\n};\n\nconst phaseProtectionEnabled = RED.util.getMessageProperty(msg, \"phase_protection.enabled\") === true;\nconst phasePower = RED.util.getMessageProperty(msg, \"grid_power_phase\") || {};\nconst phaseLimit = toNumberOrNull(RED.util.getMessageProperty(msg, \"grid_power_limit_phase\"));\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\n\nconst phaseReadings = PHASES.reduce((result, phase) => {\n result[phase] = toNumberOrNull(phasePower[phase] ?? phasePower[phase.toLowerCase()]);\n return result;\n}, {});\nconst phaseBatteryPower = {\n signed: { L1: 0, L2: 0, L3: 0 },\n charge: { L1: 0, L2: 0, L3: 0 },\n discharge: { L1: 0, L2: 0, L3: 0 },\n};\n\nbatteries.forEach((battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n if (!PHASES.includes(phase)) return;\n\n const power = Number(battery.power);\n if (!Number.isFinite(power)) return;\n\n phaseBatteryPower.signed[phase] += power;\n if (power > 0) phaseBatteryPower.charge[phase] += power;\n if (power < 0) phaseBatteryPower.discharge[phase] += Math.abs(power);\n});\n\nconst phaseSensorsAvailable = PHASES.every((phase) => phaseReadings[phase] !== null);\nconst phaseLimitAvailable = phaseLimit !== null && phaseLimit > 0;\nconst commandLimitsAvailable = phaseProtectionEnabled && phaseSensorsAvailable && phaseLimitAvailable;\nconst commandLimits = { charge: {}, discharge: {} };\n\nPHASES.forEach((phase) => {\n if (!commandLimitsAvailable) {\n commandLimits.charge[phase] = null;\n commandLimits.discharge[phase] = null;\n return;\n }\n\n // Remove the batteries' current signed contribution from the phase\n // reading so the next command is capped against the underlying phase load.\n const nonBatteryPhasePower = phaseReadings[phase] - phaseBatteryPower.signed[phase];\n commandLimits.charge[phase] = Math.max(0, Math.floor(phaseLimit - nonBatteryPhasePower));\n commandLimits.discharge[phase] = Math.max(0, Math.floor(nonBatteryPhasePower + phaseLimit));\n});\n\nRED.util.setMessageProperty(msg, \"phase_protection.command_limits_available\", commandLimitsAvailable, true);\nRED.util.setMessageProperty(msg, \"phase_protection.command_limit_by_phase\", commandLimits, true);\nRED.util.setMessageProperty(msg, \"phase_protection.phase_battery_power\", phaseBatteryPower, true);\n\nreturn msg;", + "outputs": 1, + "timeout": 0, + "noerr": 0, + "initialize": "", + "finalize": "", + "libs": [], + "x": 1960, + "y": 1040, + "wires": [ + [ + "2c2ed88ff158d0b7" + ] + ] } ] From 16067574ad056462c881754c8f5335f2c9dfb0ad Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Sun, 7 Jun 2026 16:33:43 +0200 Subject: [PATCH 08/14] Add phase water-fill allocator --- node-red/02 strategy-charge.json | 2 +- node-red/02 strategy-self-consumption.json | 2 +- node-red/02 strategy-sell.json | 2 +- node-red/all-flows-in-one-file.json | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/node-red/02 strategy-charge.json b/node-red/02 strategy-charge.json index 36b3f8b..e6b0b13 100644 --- a/node-red/02 strategy-charge.json +++ b/node-red/02 strategy-charge.json @@ -294,7 +294,7 @@ "z": "e31b0cf1ca8100ca", "g": "64710d0b7446b478", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nlet solution_array = [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseChargeRemaining = { ...phaseChargeLimits };\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction takePhaseChargePower(phase, requestedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return requestedPower;\n\n const remaining = Number(phaseChargeRemaining[phase]);\n if (!Number.isFinite(remaining)) return requestedPower;\n\n const assignedPower = Math.min(requestedPower, Math.max(0, remaining));\n phaseChargeRemaining[phase] = Math.max(0, remaining - assignedPower);\n return assignedPower;\n}\n\n// build solution\nbatteries.forEach(battery => {\n const maxChargePower = Math.max(0, Number(battery.charging_max) || 0);\n const phase = getPhase(battery);\n const calculatedPower = takePhaseChargePower(phase, maxChargePower);\n\n if (calculatedPower < maxChargePower) {\n log(this, `Battery ${battery.id}: phase ${phase} charge limit ${maxChargePower}W -> ${calculatedPower}W`);\n }\n\n solution_array.push({\n id: battery.id,\n mode: calculatedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(calculatedPower)\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nconst batteryStates = batteries.map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery.charging_max) || 0),\n assignedPower: 0,\n}));\n\nconst statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n}, {});\n\nObject.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseChargeLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = getWaterFillAllocations(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n});\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/02 strategy-self-consumption.json b/node-red/02 strategy-self-consumption.json index 20bc145..77ba57f 100644 --- a/node-red/02 strategy-self-consumption.json +++ b/node-red/02 strategy-self-consumption.json @@ -1820,7 +1820,7 @@ "z": "08bd910806aaf74c", "g": "a53d59ea9a6ce336", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPower(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n [\"L1\", \"L2\", \"L3\"].forEach((phase) => {\n let phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n batteryStates\n .filter((state) => state.phase === phase)\n .forEach((state) => {\n const assignedPower = assignPower(state, phaseRequiredPower);\n phaseRequiredPower -= assignedPower;\n phaseAssignment[phase].assigned += assignedPower;\n });\n\n phaseAssignment[phase].unassigned = phaseRequiredPower;\n if (phaseRequiredPower > 0) {\n log(this, `Phase protection ${phase}: ${phaseRequiredPower} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nbatteryStates.forEach((state) => {\n if (aggregatePowerToAssign <= 0) return;\n const assignedPower = assignPower(state, aggregatePowerToAssign);\n aggregatePowerToAssign -= assignedPower;\n aggregateAssignedPower += assignedPower;\n});\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst usePhaseWaterFill = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWaterFill(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = getWaterFillAllocations(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = getWaterFillAllocations(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhaseWaterFill) {\n return assignPowerWaterFill(candidateStates, requested);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, diff --git a/node-red/02 strategy-sell.json b/node-red/02 strategy-sell.json index 80249d0..26d03d6 100644 --- a/node-red/02 strategy-sell.json +++ b/node-red/02 strategy-sell.json @@ -251,7 +251,7 @@ "z": "68716753bacc0887", "g": "827abf066017f642", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nlet solution_array = [];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\nconst phaseDischargeRemaining = { ...phaseDischargeLimits };\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction takePhaseDischargePower(phase, requestedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return requestedPower;\n\n const remaining = Number(phaseDischargeRemaining[phase]);\n if (!Number.isFinite(remaining)) return requestedPower;\n\n const assignedPower = Math.min(requestedPower, Math.max(0, remaining));\n phaseDischargeRemaining[phase] = Math.max(0, remaining - assignedPower);\n return assignedPower;\n}\n\n// build solution\nbatteries.forEach(battery => {\n const maxDischargePower = Math.max(0, Number(battery.discharging_max) || 0);\n const phase = getPhase(battery);\n const calculatedPower = takePhaseDischargePower(phase, maxDischargePower);\n\n if (calculatedPower < maxDischargePower) {\n log(this, `Battery ${battery.id}: phase ${phase} discharge limit ${maxDischargePower}W -> ${calculatedPower}W`);\n }\n\n solution_array.push({\n id: battery.id,\n mode: calculatedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(calculatedPower)\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nconst batteryStates = batteries.map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery.discharging_max) || 0),\n assignedPower: 0,\n}));\n\nconst statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n}, {});\n\nObject.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseDischargeLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = getWaterFillAllocations(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n});\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} discharge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index 54c0586..ba67091 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -6616,7 +6616,7 @@ "z": "ab98da23bbbad975", "g": "7851e6c9bd5ff6f6", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nlet solution_array = [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseChargeRemaining = { ...phaseChargeLimits };\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction takePhaseChargePower(phase, requestedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return requestedPower;\n\n const remaining = Number(phaseChargeRemaining[phase]);\n if (!Number.isFinite(remaining)) return requestedPower;\n\n const assignedPower = Math.min(requestedPower, Math.max(0, remaining));\n phaseChargeRemaining[phase] = Math.max(0, remaining - assignedPower);\n return assignedPower;\n}\n\n// build solution\nbatteries.forEach(battery => {\n const maxChargePower = Math.max(0, Number(battery.charging_max) || 0);\n const phase = getPhase(battery);\n const calculatedPower = takePhaseChargePower(phase, maxChargePower);\n\n if (calculatedPower < maxChargePower) {\n log(this, `Battery ${battery.id}: phase ${phase} charge limit ${maxChargePower}W -> ${calculatedPower}W`);\n }\n\n solution_array.push({\n id: battery.id,\n mode: calculatedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(calculatedPower)\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nconst batteryStates = batteries.map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery.charging_max) || 0),\n assignedPower: 0,\n}));\n\nconst statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n}, {});\n\nObject.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseChargeLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = getWaterFillAllocations(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n});\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -13248,7 +13248,7 @@ "z": "489c548637b42621", "g": "0d27c0ba873a6bdd", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPower(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n [\"L1\", \"L2\", \"L3\"].forEach((phase) => {\n let phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n batteryStates\n .filter((state) => state.phase === phase)\n .forEach((state) => {\n const assignedPower = assignPower(state, phaseRequiredPower);\n phaseRequiredPower -= assignedPower;\n phaseAssignment[phase].assigned += assignedPower;\n });\n\n phaseAssignment[phase].unassigned = phaseRequiredPower;\n if (phaseRequiredPower > 0) {\n log(this, `Phase protection ${phase}: ${phaseRequiredPower} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nbatteryStates.forEach((state) => {\n if (aggregatePowerToAssign <= 0) return;\n const assignedPower = assignPower(state, aggregatePowerToAssign);\n aggregatePowerToAssign -= assignedPower;\n aggregateAssignedPower += assignedPower;\n});\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst usePhaseWaterFill = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWaterFill(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = getWaterFillAllocations(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = getWaterFillAllocations(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhaseWaterFill) {\n return assignPowerWaterFill(candidateStates, requested);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, @@ -13888,7 +13888,7 @@ "z": "9502ef431612a690", "g": "cda87dc2fda3f49a", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nlet solution_array = [];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\nconst phaseDischargeRemaining = { ...phaseDischargeLimits };\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return [\"L1\", \"L2\", \"L3\"].includes(phase) ? phase : \"unassigned\";\n}\n\nfunction takePhaseDischargePower(phase, requestedPower) {\n if (![\"L1\", \"L2\", \"L3\"].includes(phase)) return requestedPower;\n\n const remaining = Number(phaseDischargeRemaining[phase]);\n if (!Number.isFinite(remaining)) return requestedPower;\n\n const assignedPower = Math.min(requestedPower, Math.max(0, remaining));\n phaseDischargeRemaining[phase] = Math.max(0, remaining - assignedPower);\n return assignedPower;\n}\n\n// build solution\nbatteries.forEach(battery => {\n const maxDischargePower = Math.max(0, Number(battery.discharging_max) || 0);\n const phase = getPhase(battery);\n const calculatedPower = takePhaseDischargePower(phase, maxDischargePower);\n\n if (calculatedPower < maxDischargePower) {\n log(this, `Battery ${battery.id}: phase ${phase} discharge limit ${maxDischargePower}W -> ${calculatedPower}W`);\n }\n\n solution_array.push({\n id: battery.id,\n mode: calculatedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(calculatedPower)\n });\n\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nconst batteryStates = batteries.map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery.discharging_max) || 0),\n assignedPower: 0,\n}));\n\nconst statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n}, {});\n\nObject.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseDischargeLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = getWaterFillAllocations(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n});\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} discharge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, From d5ce48f3afa634910f643bc60f1fd7464048ec35 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Sun, 7 Jun 2026 22:52:09 +0200 Subject: [PATCH 09/14] Deduplicate phase allocator logic --- node-red/01 start-flow.json | 2 +- node-red/02 strategy-charge.json | 2 +- node-red/02 strategy-self-consumption.json | 2 +- node-red/02 strategy-sell.json | 2 +- node-red/all-flows-in-one-file.json | 8 ++++---- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index 6d0d172..5570616 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -2543,7 +2543,7 @@ "z": "cf69560481408644", "g": "1d6d27611b4eda6c", "name": "Custom logger", - "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", + "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {} }) {\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery?.[maxPowerProperty]) || 0),\n assignedPower: 0,\n }));\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = allocateWaterFill(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, allocateWaterFill, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/02 strategy-charge.json b/node-red/02 strategy-charge.json index e6b0b13..581d0a6 100644 --- a/node-red/02 strategy-charge.json +++ b/node-red/02 strategy-charge.json @@ -294,7 +294,7 @@ "z": "e31b0cf1ca8100ca", "g": "64710d0b7446b478", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nconst batteryStates = batteries.map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery.charging_max) || 0),\n assignedPower: 0,\n}));\n\nconst statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n}, {});\n\nObject.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseChargeLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = getWaterFillAllocations(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n});\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseChargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.charging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"charging_max\", phaseLimits: phaseChargeLimits })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/02 strategy-self-consumption.json b/node-red/02 strategy-self-consumption.json index 77ba57f..54e82a5 100644 --- a/node-red/02 strategy-self-consumption.json +++ b/node-red/02 strategy-self-consumption.json @@ -1820,7 +1820,7 @@ "z": "08bd910806aaf74c", "g": "a53d59ea9a6ce336", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst usePhaseWaterFill = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWaterFill(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = getWaterFillAllocations(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = getWaterFillAllocations(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhaseWaterFill) {\n return assignPowerWaterFill(candidateStates, requested);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseWaterFill = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true\n && typeof phaseAllocator.allocateWaterFill === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWaterFill(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = phaseAllocator.allocateWaterFill(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = phaseAllocator.allocateWaterFill(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhaseWaterFill) {\n return assignPowerWaterFill(candidateStates, requested);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, diff --git a/node-red/02 strategy-sell.json b/node-red/02 strategy-sell.json index 26d03d6..dc88009 100644 --- a/node-red/02 strategy-sell.json +++ b/node-red/02 strategy-sell.json @@ -251,7 +251,7 @@ "z": "68716753bacc0887", "g": "827abf066017f642", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nconst batteryStates = batteries.map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery.discharging_max) || 0),\n assignedPower: 0,\n}));\n\nconst statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n}, {});\n\nObject.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseDischargeLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = getWaterFillAllocations(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n});\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} discharge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseDischargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.discharging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"discharging_max\", phaseLimits: phaseDischargeLimits })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} discharge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index ba67091..3637679 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -4977,7 +4977,7 @@ "z": "419f395a5f52024b", "g": "0eb89bb2e8918ed0", "name": "Custom logger", - "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", + "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {} }) {\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery?.[maxPowerProperty]) || 0),\n assignedPower: 0,\n }));\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = allocateWaterFill(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, allocateWaterFill, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6616,7 +6616,7 @@ "z": "ab98da23bbbad975", "g": "7851e6c9bd5ff6f6", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nconst batteryStates = batteries.map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery.charging_max) || 0),\n assignedPower: 0,\n}));\n\nconst statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n}, {});\n\nObject.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseChargeLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = getWaterFillAllocations(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n});\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseChargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.charging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"charging_max\", phaseLimits: phaseChargeLimits })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -13248,7 +13248,7 @@ "z": "489c548637b42621", "g": "0d27c0ba873a6bdd", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst usePhaseWaterFill = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWaterFill(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = getWaterFillAllocations(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = getWaterFillAllocations(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhaseWaterFill) {\n return assignPowerWaterFill(candidateStates, requested);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseWaterFill = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true\n && typeof phaseAllocator.allocateWaterFill === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWaterFill(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = phaseAllocator.allocateWaterFill(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = phaseAllocator.allocateWaterFill(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhaseWaterFill) {\n return assignPowerWaterFill(candidateStates, requested);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, @@ -13888,7 +13888,7 @@ "z": "9502ef431612a690", "g": "cda87dc2fda3f49a", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nlet batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst PHASES = [\"L1\", \"L2\", \"L3\"];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\n\nfunction getPhase(battery) {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getWaterFillAllocations(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n}\n\nconst batteryStates = batteries.map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery.discharging_max) || 0),\n assignedPower: 0,\n}));\n\nconst statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n}, {});\n\nObject.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseDischargeLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = getWaterFillAllocations(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n});\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} discharge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseDischargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.discharging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"discharging_max\", phaseLimits: phaseDischargeLimits })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} discharge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, From 9154abf80436edf6dc6b3001711591bddd14e120 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Sat, 13 Jun 2026 13:04:34 +0200 Subject: [PATCH 10/14] Preserve charge priority under phase throttling --- node-red/01 start-flow.json | 2 +- node-red/02 strategy-charge.json | 2 +- node-red/02 strategy-self-consumption.json | 2 +- node-red/all-flows-in-one-file.json | 6 +++--- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index 5570616..3e06663 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -2543,7 +2543,7 @@ "z": "cf69560481408644", "g": "1d6d27611b4eda6c", "name": "Custom logger", - "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {} }) {\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery?.[maxPowerProperty]) || 0),\n assignedPower: 0,\n }));\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = allocateWaterFill(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, allocateWaterFill, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", + "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function allocatePriorityFirst(items, budget, getCapacity) {\n const allocations = new Map(items.map((item) => [item, 0]));\n let remainingBudget = Math.max(0, Math.floor(Number(budget) || 0));\n\n items.forEach((item) => {\n if (remainingBudget <= 0) return;\n\n const capacity = Math.max(0, Number(getCapacity(item)) || 0);\n const assignedPower = Math.min(capacity, remainingBudget);\n allocations.set(item, assignedPower);\n remainingBudget -= assignedPower;\n });\n\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {}, allocationMode = \"water-fill\" }) {\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery?.[maxPowerProperty]) || 0),\n assignedPower: 0,\n }));\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocator = allocationMode === \"priority-first\" ? allocatePriorityFirst : allocateWaterFill;\n const allocations = allocator(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, allocateWaterFill, allocatePriorityFirst, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/02 strategy-charge.json b/node-red/02 strategy-charge.json index 581d0a6..8c9cc3d 100644 --- a/node-red/02 strategy-charge.json +++ b/node-red/02 strategy-charge.json @@ -294,7 +294,7 @@ "z": "e31b0cf1ca8100ca", "g": "64710d0b7446b478", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseChargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.charging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"charging_max\", phaseLimits: phaseChargeLimits })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseChargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.charging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"charging_max\", phaseLimits: phaseChargeLimits, allocationMode: \"priority-first\" })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/02 strategy-self-consumption.json b/node-red/02 strategy-self-consumption.json index 54e82a5..7815580 100644 --- a/node-red/02 strategy-self-consumption.json +++ b/node-red/02 strategy-self-consumption.json @@ -1820,7 +1820,7 @@ "z": "08bd910806aaf74c", "g": "a53d59ea9a6ce336", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseWaterFill = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true\n && typeof phaseAllocator.allocateWaterFill === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWaterFill(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = phaseAllocator.allocateWaterFill(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = phaseAllocator.allocateWaterFill(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhaseWaterFill) {\n return assignPowerWaterFill(candidateStates, requested);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseAllocator = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst usePhaseWaterFill = usePhaseAllocator && !isCharging && typeof phaseAllocator.allocateWaterFill === \"function\";\nconst usePhasePriorityFirst = usePhaseAllocator && isCharging && typeof phaseAllocator.allocatePriorityFirst === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWithAllocator(candidateStates, requestedPower, allocator) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = allocator(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = allocator(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhasePriorityFirst) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocatePriorityFirst);\n }\n if (usePhaseWaterFill) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocateWaterFill);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index 3637679..a6054b1 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -4977,7 +4977,7 @@ "z": "419f395a5f52024b", "g": "0eb89bb2e8918ed0", "name": "Custom logger", - "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {} }) {\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery?.[maxPowerProperty]) || 0),\n assignedPower: 0,\n }));\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocations = allocateWaterFill(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, allocateWaterFill, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", + "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function allocatePriorityFirst(items, budget, getCapacity) {\n const allocations = new Map(items.map((item) => [item, 0]));\n let remainingBudget = Math.max(0, Math.floor(Number(budget) || 0));\n\n items.forEach((item) => {\n if (remainingBudget <= 0) return;\n\n const capacity = Math.max(0, Number(getCapacity(item)) || 0);\n const assignedPower = Math.min(capacity, remainingBudget);\n allocations.set(item, assignedPower);\n remainingBudget -= assignedPower;\n });\n\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {}, allocationMode = \"water-fill\" }) {\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery?.[maxPowerProperty]) || 0),\n assignedPower: 0,\n }));\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocator = allocationMode === \"priority-first\" ? allocatePriorityFirst : allocateWaterFill;\n const allocations = allocator(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, allocateWaterFill, allocatePriorityFirst, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -6616,7 +6616,7 @@ "z": "ab98da23bbbad975", "g": "7851e6c9bd5ff6f6", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseChargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.charging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"charging_max\", phaseLimits: phaseChargeLimits })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseChargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.charging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"charging_max\", phaseLimits: phaseChargeLimits, allocationMode: \"priority-first\" })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -13248,7 +13248,7 @@ "z": "489c548637b42621", "g": "0d27c0ba873a6bdd", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseWaterFill = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true\n && typeof phaseAllocator.allocateWaterFill === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWaterFill(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = phaseAllocator.allocateWaterFill(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = phaseAllocator.allocateWaterFill(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhaseWaterFill) {\n return assignPowerWaterFill(candidateStates, requested);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseAllocator = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst usePhaseWaterFill = usePhaseAllocator && !isCharging && typeof phaseAllocator.allocateWaterFill === \"function\";\nconst usePhasePriorityFirst = usePhaseAllocator && isCharging && typeof phaseAllocator.allocatePriorityFirst === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWithAllocator(candidateStates, requestedPower, allocator) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = allocator(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = allocator(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhasePriorityFirst) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocatePriorityFirst);\n }\n if (usePhaseWaterFill) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocateWaterFill);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, From caf4f55496f387497da77502fd4d0f386708a870 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Mon, 15 Jun 2026 08:26:42 +0200 Subject: [PATCH 11/14] Redistribute unused phase charge allowance --- node-red/01 start-flow.json | 8 ++++---- node-red/02 strategy-self-consumption.json | 2 +- node-red/all-flows-in-one-file.json | 10 +++++----- 3 files changed, 10 insertions(+), 10 deletions(-) diff --git a/node-red/01 start-flow.json b/node-red/01 start-flow.json index 3e06663..d81417c 100644 --- a/node-red/01 start-flow.json +++ b/node-red/01 start-flow.json @@ -682,7 +682,7 @@ "z": "cf69560481408644", "g": "98643475a97c956a", "name": "Mapping", - "func": "// Normalize optional phase assignment\nconst normalizedPhase = String(msg.battery_phase || \"\").trim().toUpperCase();\nconst phase = [\"L1\", \"L2\", \"L3\"].includes(normalizedPhase) ? normalizedPhase : \"unassigned\";\n\n// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n phase: phase,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", + "func": "// Normalize optional phase assignment\nconst normalizedPhase = String(msg.battery_phase || \"\").trim().toUpperCase();\nconst phase = [\"L1\", \"L2\", \"L3\"].includes(normalizedPhase) ? normalizedPhase : \"unassigned\";\n\nconst batteryId = `M${msg.battery_index}`;\nconst lastBatteryCommands = global.get(\"lastBatteryCommands\") || {};\nconst lastCommand = lastBatteryCommands[batteryId] || null;\n\n// Map to batteries array\nmsg.batteries.push({\n id: batteryId,\n phase: phase,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode),\n last_command: lastCommand\n });\n\n// Continue\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -1254,7 +1254,7 @@ "z": "cf69560481408644", "g": "d6c9e642f1579c6b", "name": "Set Batteries", - "func": "// Logger\nconst log = global.get(\"logger\");\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// Output format and target\nlet outputArray = [];\nlet solution = msg.solutions[msg.battery_index-1]; // Base-0, thus -1\nif (solution === undefined) {\n log(this,`Can't find solution for index ${msg.battery_index - 1} in ${msg.solutions}, aborting...`,\"error\");\n return null;\n}\nlet target = `marstek_${solution.id.toLowerCase()}`;\n\n// Safety | Id exists\nlet battery = msg.batteries.find(battery => battery.id === solution.id);\nif (battery === undefined) {\n log(`Can't set battery ${solution.id}, aborting...`,\"error\");\n return null;\n}\n// Safety | Power limit\nsolution.power = Math.min(solution.power, solution.mode == CMODE.CHARGE ? battery.charging_max: battery.discharging_max);\n\n// Set | Mode\nconst serviceCallMode = {\n \"action\": \"select.select_option\",\n \"target\": {\n \"entity_id\": [`select.${target}_forcible_charge_discharge`]\n },\n \"data\": {\n \"option\": String(solution.mode) // forced charge mode (stop, charge, discharge)\n }\n};\n// Add topic to allow correct 'on change' filtering\noutputArray.push({payload: serviceCallMode, topic:solution.id});\n\n// Set | Power\nconst serviceCallPower = {\n \"action\": \"number.set_value\",\n \"target\": {\n \"entity_id\": [\n `number.${target}_forcible_charge_power`,\n `number.${target}_forcible_discharge_power`\n ]\n },\n \"data\": {\n \"value\": Number(solution.power)\n } \n};\n// Add topic to allow correct 'on change' filtering\noutputArray.push({payload: serviceCallPower, topic:solution.id});\n\n// Store service calls in msg to allow checking of loop results\nmsg.loop_result = { target, serviceCallMode, serviceCallPower };\n\n// Return msg as third output\noutputArray.push(msg);\n\n// Explain\nlog(this,`${target} ${String(solution.mode)} @ ${Number(solution.power)} Watt`);\n\n// Output\nreturn outputArray;", + "func": "// Logger\nconst log = global.get(\"logger\");\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// Output format and target\nlet outputArray = [];\nlet solution = msg.solutions[msg.battery_index-1]; // Base-0, thus -1\nif (solution === undefined) {\n log(this,`Can't find solution for index ${msg.battery_index - 1} in ${msg.solutions}, aborting...`,\"error\");\n return null;\n}\nlet target = `marstek_${solution.id.toLowerCase()}`;\n\n// Safety | Id exists\nlet battery = msg.batteries.find(battery => battery.id === solution.id);\nif (battery === undefined) {\n log(`Can't set battery ${solution.id}, aborting...`,\"error\");\n return null;\n}\n// Safety | Power limit\nsolution.power = Math.min(solution.power, solution.mode == CMODE.CHARGE ? battery.charging_max: battery.discharging_max);\n\nconst lastBatteryCommands = global.get(\"lastBatteryCommands\") || {};\nconst previousCommand = lastBatteryCommands[solution.id] || {};\nconst previousPower = Number(previousCommand.power);\nconst currentPower = Number(solution.power) || 0;\nconst now = Date.now();\nconst commandChanged = previousCommand.mode !== solution.mode\n || !Number.isFinite(previousPower)\n || Math.abs(previousPower - currentPower) > 25;\nlastBatteryCommands[solution.id] = {\n mode: String(solution.mode),\n power: currentPower,\n since: commandChanged ? now : (Number(previousCommand.since) || now),\n updated: now,\n};\nglobal.set(\"lastBatteryCommands\", lastBatteryCommands);\n\n// Set | Mode\nconst serviceCallMode = {\n \"action\": \"select.select_option\",\n \"target\": {\n \"entity_id\": [`select.${target}_forcible_charge_discharge`]\n },\n \"data\": {\n \"option\": String(solution.mode) // forced charge mode (stop, charge, discharge)\n }\n};\n// Add topic to allow correct 'on change' filtering\noutputArray.push({payload: serviceCallMode, topic:solution.id});\n\n// Set | Power\nconst serviceCallPower = {\n \"action\": \"number.set_value\",\n \"target\": {\n \"entity_id\": [\n `number.${target}_forcible_charge_power`,\n `number.${target}_forcible_discharge_power`\n ]\n },\n \"data\": {\n \"value\": Number(solution.power)\n } \n};\n// Add topic to allow correct 'on change' filtering\noutputArray.push({payload: serviceCallPower, topic:solution.id});\n\n// Store service calls in msg to allow checking of loop results\nmsg.loop_result = { target, serviceCallMode, serviceCallPower };\n\n// Return msg as third output\noutputArray.push(msg);\n\n// Explain\nlog(this,`${target} ${String(solution.mode)} @ ${Number(solution.power)} Watt`);\n\n// Output\nreturn outputArray;", "outputs": 3, "timeout": 0, "noerr": 0, @@ -1549,7 +1549,7 @@ "z": "cf69560481408644", "g": "f80c67f5392fd743", "name": "Update battery order", - "func": "// Cycles battery priority as follows\n// M=1 [1,2,3,4] | M=2 [2,3,4,1] | M=3 [3,4,1,2] | etc.\n// With M the battery to prioritize\n\n// msg.batteries (original order)\nlet currentArray = msg.batteries;\n\n// Prioritize the Mth battery\nconst M = RED.util.getMessageProperty(msg,\"prioritize_battery\") || 1;\n\n// Flag wether the order may be reversed when discharging (e.g. Self-consumption utilizes this)\nconst reverseDischargePriority = RED.util.getMessageProperty(msg,\"battery_priority_cycle_interval\") !== \"Auto balance\";\nRED.util.setMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\", reverseDischargePriority,true);\n\n// 1st battery has prio? Skip and continue without change\nif (M==1) return msg;\n\n// N equals the number of items to take from the front to the back of the array, effectivly rotating battery priority\nconst N = M - 1; // base-0 version of M\nnode.status({fill:\"blue\",shape:\"ring\",text:`N = ${N}`});\n\n// 1. Check if the array is valid and long enough\nif (!Array.isArray(currentArray) || currentArray.length < N) {\n // Send an error or the original array back if the array is invalid/too short\n node.error(`Array is not valid or shorter than ${N} items.`, msg);\n return null;\n}\n\n// 2. Get the first N items (these will go to the back)\n// slice(0, N) retrieves elements from the start (index 0) up to index N (exclusive).\nlet firstN = currentArray.slice(0, N);\n\n// 3. Get the remaining items (these will stay at the front)\n// slice(N) retrieves elements from index N to the end of the array.\nlet remaining = currentArray.slice(N);\n\n// 4. Concatenate them: remaining items (front) + first N items (back)\nlet newArray = remaining.concat(firstN);\n\n// 5. Place the new array back into msg.batteries\nmsg.batteries = newArray;\n\nreturn msg;\n", + "func": "// Cycles battery priority as follows\n// M=1 [1,2,3,4] | M=2 [2,3,4,1] | M=3 [3,4,1,2] | etc.\n// With M the battery to prioritize\n\n// msg.batteries (original order)\nlet currentArray = msg.batteries;\n\n// Prioritize the Mth battery\nconst M = RED.util.getMessageProperty(msg,\"prioritize_battery\") || 1;\n\nif (Array.isArray(currentArray)) {\n currentArray.forEach((battery) => {\n if (battery && typeof battery === \"object\") battery.is_priority_battery = false;\n });\n\n const priorityBattery = currentArray[M - 1];\n if (priorityBattery && typeof priorityBattery === \"object\") {\n priorityBattery.is_priority_battery = true;\n RED.util.setMessageProperty(msg, \"priority_battery_id\", priorityBattery.id, true);\n }\n}\n\n// Flag wether the order may be reversed when discharging (e.g. Self-consumption utilizes this)\nconst reverseDischargePriority = RED.util.getMessageProperty(msg,\"battery_priority_cycle_interval\") !== \"Auto balance\";\nRED.util.setMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\", reverseDischargePriority,true);\n\n// 1st battery has prio? Skip and continue without change\nif (M==1) return msg;\n\n// N equals the number of items to take from the front to the back of the array, effectivly rotating battery priority\nconst N = M - 1; // base-0 version of M\nnode.status({fill:\"blue\",shape:\"ring\",text:`N = ${N}`});\n\n// 1. Check if the array is valid and long enough\nif (!Array.isArray(currentArray) || currentArray.length < N) {\n // Send an error or the original array back if the array is invalid/too short\n node.error(`Array is not valid or shorter than ${N} items.`, msg);\n return null;\n}\n\n// 2. Get the first N items (these will go to the back)\n// slice(0, N) retrieves elements from the start (index 0) up to index N (exclusive).\nlet firstN = currentArray.slice(0, N);\n\n// 3. Get the remaining items (these will stay at the front)\n// slice(N) retrieves elements from index N to the end of the array.\nlet remaining = currentArray.slice(N);\n\n// 4. Concatenate them: remaining items (front) + first N items (back)\nlet newArray = remaining.concat(firstN);\n\n// 5. Place the new array back into msg.batteries\nmsg.batteries = newArray;\n\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -2543,7 +2543,7 @@ "z": "cf69560481408644", "g": "1d6d27611b4eda6c", "name": "Custom logger", - "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function allocatePriorityFirst(items, budget, getCapacity) {\n const allocations = new Map(items.map((item) => [item, 0]));\n let remainingBudget = Math.max(0, Math.floor(Number(budget) || 0));\n\n items.forEach((item) => {\n if (remainingBudget <= 0) return;\n\n const capacity = Math.max(0, Number(getCapacity(item)) || 0);\n const assignedPower = Math.min(capacity, remainingBudget);\n allocations.set(item, assignedPower);\n remainingBudget -= assignedPower;\n });\n\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {}, allocationMode = \"water-fill\" }) {\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery?.[maxPowerProperty]) || 0),\n assignedPower: 0,\n }));\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocator = allocationMode === \"priority-first\" ? allocatePriorityFirst : allocateWaterFill;\n const allocations = allocator(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, allocateWaterFill, allocatePriorityFirst, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", + "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const OBSERVED_CHARGE_HEADROOM_W = 100;\n const UNDERUSE_DETECTION_DELAY_MS = 10000;\n const UNDERUSE_MARGIN_W = 100;\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function getModeFromMaxPowerProperty(maxPowerProperty) {\n return String(maxPowerProperty) === \"charging_max\" ? \"charge\" : \"discharge\";\n }\n\n function getObservedModePower(battery) {\n const power = Number(battery?.power);\n return Number.isFinite(power) ? Math.max(0, Math.abs(power)) : null;\n }\n\n function getLastCommand(battery) {\n return battery?.last_command || battery?.lastCommand || null;\n }\n\n function getResponsivePowerLimit(battery, maxPower, mode) {\n const requestedPower = Math.max(0, Number(maxPower) || 0);\n if (requestedPower <= 0) return 0;\n\n const soc = Number(battery?.soc);\n const socMax = Number(battery?.soc_max);\n const socMin = Number(battery?.soc_min);\n\n if (mode === \"charge\" && Number.isFinite(soc) && Number.isFinite(socMax) && soc >= socMax) {\n return 0;\n }\n if (mode === \"discharge\" && Number.isFinite(soc) && Number.isFinite(socMin) && soc <= socMin) {\n return 0;\n }\n if (mode !== \"charge\") {\n return requestedPower;\n }\n\n const observedPower = getObservedModePower(battery);\n const lastCommand = getLastCommand(battery);\n const lastMode = String(lastCommand?.mode || \"\").toLowerCase();\n const lastPower = Number(lastCommand?.power);\n if (observedPower === null || lastMode !== mode || !Number.isFinite(lastPower) || lastPower <= 0) {\n return requestedPower;\n }\n\n const since = Number(lastCommand?.since ?? lastCommand?.updated);\n const commandAgeMs = Number.isFinite(since) ? Math.max(0, Date.now() - since) : 0;\n const nearSocCeiling = Number.isFinite(soc) && Number.isFinite(socMax) && soc >= Math.max(0, socMax - 5);\n const sustainedUnderuse = commandAgeMs >= UNDERUSE_DETECTION_DELAY_MS\n && observedPower + UNDERUSE_MARGIN_W < Math.min(lastPower, requestedPower);\n\n if (!nearSocCeiling && !sustainedUnderuse) {\n return requestedPower;\n }\n\n return Math.min(requestedPower, Math.floor(observedPower + OBSERVED_CHARGE_HEADROOM_W));\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function itemHasPriority(item) {\n if (Array.isArray(item?.entries)) {\n return item.entries.some((entry) => itemHasPriority(entry));\n }\n\n const battery = item?.battery || item?.state?.battery || item;\n return battery?.is_priority_battery === true;\n }\n\n function allocatePriorityWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n const priorityEntries = entries.filter((entry) => itemHasPriority(entry.item));\n if (priorityEntries.length === 0) {\n return allocateWaterFill(items, targetPower, getCapacity);\n }\n\n const regularEntries = entries.filter((entry) => !itemHasPriority(entry.item));\n const priorityCapacity = priorityEntries.reduce((sum, entry) => sum + entry.capacity, 0);\n const priorityBudget = Math.min(targetPower, priorityCapacity);\n const priorityAllocations = allocateWaterFill(priorityEntries, priorityBudget, (entry) => entry.capacity);\n let assignedPriorityPower = 0;\n\n priorityEntries.forEach((entry) => {\n const assignedPower = priorityAllocations.get(entry) || 0;\n allocations.set(entry.item, assignedPower);\n assignedPriorityPower += assignedPower;\n });\n\n const regularBudget = targetPower - assignedPriorityPower;\n if (regularBudget <= 0 || regularEntries.length === 0) {\n return allocations;\n }\n\n const regularAllocations = allocateWaterFill(regularEntries, regularBudget, (entry) => entry.capacity);\n regularEntries.forEach((entry) => {\n allocations.set(entry.item, regularAllocations.get(entry) || 0);\n });\n\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {}, allocationMode = \"water-fill\" }) {\n const mode = getModeFromMaxPowerProperty(maxPowerProperty);\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => {\n const maxPower = Math.max(0, Number(battery?.[maxPowerProperty]) || 0);\n return {\n battery,\n phase: getPhase(battery),\n requestedPower: getResponsivePowerLimit(battery, maxPower, mode),\n assignedPower: 0,\n };\n });\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocator = allocationMode === \"priority-first\" ? allocatePriorityWaterFill : allocateWaterFill;\n const allocations = allocator(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, getResponsivePowerLimit, allocateWaterFill, allocatePriorityWaterFill, allocatePriorityFirst: allocatePriorityWaterFill, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/02 strategy-self-consumption.json b/node-red/02 strategy-self-consumption.json index 7815580..cf916a9 100644 --- a/node-red/02 strategy-self-consumption.json +++ b/node-red/02 strategy-self-consumption.json @@ -1820,7 +1820,7 @@ "z": "08bd910806aaf74c", "g": "a53d59ea9a6ce336", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseAllocator = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst usePhaseWaterFill = usePhaseAllocator && !isCharging && typeof phaseAllocator.allocateWaterFill === \"function\";\nconst usePhasePriorityFirst = usePhaseAllocator && isCharging && typeof phaseAllocator.allocatePriorityFirst === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWithAllocator(candidateStates, requestedPower, allocator) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = allocator(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = allocator(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhasePriorityFirst) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocatePriorityFirst);\n }\n if (usePhaseWaterFill) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocateWaterFill);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseAllocator = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst usePhaseWaterFill = usePhaseAllocator && !isCharging && typeof phaseAllocator.allocateWaterFill === \"function\";\nconst usePhasePriorityFirst = usePhaseAllocator && isCharging && typeof phaseAllocator.allocatePriorityWaterFill === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const maxAssignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n const assignablePower = skipReason === null && isCharging && typeof phaseAllocator.getResponsivePowerLimit === \"function\"\n ? phaseAllocator.getResponsivePowerLimit(battery, maxAssignablePower, \"charge\")\n : maxAssignablePower;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWithAllocator(candidateStates, requestedPower, allocator) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = allocator(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = allocator(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhasePriorityFirst) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocatePriorityWaterFill);\n }\n if (usePhaseWaterFill) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocateWaterFill);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, diff --git a/node-red/all-flows-in-one-file.json b/node-red/all-flows-in-one-file.json index a6054b1..c3ce3d4 100644 --- a/node-red/all-flows-in-one-file.json +++ b/node-red/all-flows-in-one-file.json @@ -3116,7 +3116,7 @@ "z": "419f395a5f52024b", "g": "a7c766e9d6b9fce6", "name": "Mapping", - "func": "// Normalize optional phase assignment\nconst normalizedPhase = String(msg.battery_phase || \"\").trim().toUpperCase();\nconst phase = [\"L1\", \"L2\", \"L3\"].includes(normalizedPhase) ? normalizedPhase : \"unassigned\";\n\n// Map to batteries array\nmsg.batteries.push({\n id: `M${msg.battery_index}`,\n phase: phase,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode)\n });\n\n// Continue\nreturn msg;", + "func": "// Normalize optional phase assignment\nconst normalizedPhase = String(msg.battery_phase || \"\").trim().toUpperCase();\nconst phase = [\"L1\", \"L2\", \"L3\"].includes(normalizedPhase) ? normalizedPhase : \"unassigned\";\n\nconst batteryId = `M${msg.battery_index}`;\nconst lastBatteryCommands = global.get(\"lastBatteryCommands\") || {};\nconst lastCommand = lastBatteryCommands[batteryId] || null;\n\n// Map to batteries array\nmsg.batteries.push({\n id: batteryId,\n phase: phase,\n power: parseInt(msg.battery_power,10),\n charging_max: parseInt(msg.max_charge_power,10),\n discharging_max: parseInt(msg.max_discharge_power,10),\n soc: parseInt(msg.battery_state_of_charge,10),\n soc_max: parseInt(msg.charging_cutoff_capacity,10),\n soc_min: parseInt(msg.discharging_cutoff_capacity,10),\n inverter: String(msg.inverter_state),\n energy: Number(msg.battery_remaining_capacity),\n energy_max: Number(msg.battery_total_energy),\n rs485: String(msg.rs485_control_mode),\n last_command: lastCommand\n });\n\n// Continue\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -3688,7 +3688,7 @@ "z": "419f395a5f52024b", "g": "13f840f5a9342440", "name": "Set Batteries", - "func": "// Logger\nconst log = global.get(\"logger\");\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// Output format and target\nlet outputArray = [];\nlet solution = msg.solutions[msg.battery_index-1]; // Base-0, thus -1\nif (solution === undefined) {\n log(this,`Can't find solution for index ${msg.battery_index - 1} in ${msg.solutions}, aborting...`,\"error\");\n return null;\n}\nlet target = `marstek_${solution.id.toLowerCase()}`;\n\n// Safety | Id exists\nlet battery = msg.batteries.find(battery => battery.id === solution.id);\nif (battery === undefined) {\n log(`Can't set battery ${solution.id}, aborting...`,\"error\");\n return null;\n}\n// Safety | Power limit\nsolution.power = Math.min(solution.power, solution.mode == CMODE.CHARGE ? battery.charging_max: battery.discharging_max);\n\n// Set | Mode\nconst serviceCallMode = {\n \"action\": \"select.select_option\",\n \"target\": {\n \"entity_id\": [`select.${target}_forcible_charge_discharge`]\n },\n \"data\": {\n \"option\": String(solution.mode) // forced charge mode (stop, charge, discharge)\n }\n};\n// Add topic to allow correct 'on change' filtering\noutputArray.push({payload: serviceCallMode, topic:solution.id});\n\n// Set | Power\nconst serviceCallPower = {\n \"action\": \"number.set_value\",\n \"target\": {\n \"entity_id\": [\n `number.${target}_forcible_charge_power`,\n `number.${target}_forcible_discharge_power`\n ]\n },\n \"data\": {\n \"value\": Number(solution.power)\n } \n};\n// Add topic to allow correct 'on change' filtering\noutputArray.push({payload: serviceCallPower, topic:solution.id});\n\n// Store service calls in msg to allow checking of loop results\nmsg.loop_result = { target, serviceCallMode, serviceCallPower };\n\n// Return msg as third output\noutputArray.push(msg);\n\n// Explain\nlog(this,`${target} ${String(solution.mode)} @ ${Number(solution.power)} Watt`);\n\n// Output\nreturn outputArray;", + "func": "// Logger\nconst log = global.get(\"logger\");\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// Output format and target\nlet outputArray = [];\nlet solution = msg.solutions[msg.battery_index-1]; // Base-0, thus -1\nif (solution === undefined) {\n log(this,`Can't find solution for index ${msg.battery_index - 1} in ${msg.solutions}, aborting...`,\"error\");\n return null;\n}\nlet target = `marstek_${solution.id.toLowerCase()}`;\n\n// Safety | Id exists\nlet battery = msg.batteries.find(battery => battery.id === solution.id);\nif (battery === undefined) {\n log(`Can't set battery ${solution.id}, aborting...`,\"error\");\n return null;\n}\n// Safety | Power limit\nsolution.power = Math.min(solution.power, solution.mode == CMODE.CHARGE ? battery.charging_max: battery.discharging_max);\n\nconst lastBatteryCommands = global.get(\"lastBatteryCommands\") || {};\nconst previousCommand = lastBatteryCommands[solution.id] || {};\nconst previousPower = Number(previousCommand.power);\nconst currentPower = Number(solution.power) || 0;\nconst now = Date.now();\nconst commandChanged = previousCommand.mode !== solution.mode\n || !Number.isFinite(previousPower)\n || Math.abs(previousPower - currentPower) > 25;\nlastBatteryCommands[solution.id] = {\n mode: String(solution.mode),\n power: currentPower,\n since: commandChanged ? now : (Number(previousCommand.since) || now),\n updated: now,\n};\nglobal.set(\"lastBatteryCommands\", lastBatteryCommands);\n\n// Set | Mode\nconst serviceCallMode = {\n \"action\": \"select.select_option\",\n \"target\": {\n \"entity_id\": [`select.${target}_forcible_charge_discharge`]\n },\n \"data\": {\n \"option\": String(solution.mode) // forced charge mode (stop, charge, discharge)\n }\n};\n// Add topic to allow correct 'on change' filtering\noutputArray.push({payload: serviceCallMode, topic:solution.id});\n\n// Set | Power\nconst serviceCallPower = {\n \"action\": \"number.set_value\",\n \"target\": {\n \"entity_id\": [\n `number.${target}_forcible_charge_power`,\n `number.${target}_forcible_discharge_power`\n ]\n },\n \"data\": {\n \"value\": Number(solution.power)\n } \n};\n// Add topic to allow correct 'on change' filtering\noutputArray.push({payload: serviceCallPower, topic:solution.id});\n\n// Store service calls in msg to allow checking of loop results\nmsg.loop_result = { target, serviceCallMode, serviceCallPower };\n\n// Return msg as third output\noutputArray.push(msg);\n\n// Explain\nlog(this,`${target} ${String(solution.mode)} @ ${Number(solution.power)} Watt`);\n\n// Output\nreturn outputArray;", "outputs": 3, "timeout": 0, "noerr": 0, @@ -3983,7 +3983,7 @@ "z": "419f395a5f52024b", "g": "89b5306ee3fc3880", "name": "Update battery order", - "func": "// Cycles battery priority as follows\n// M=1 [1,2,3,4] | M=2 [2,3,4,1] | M=3 [3,4,1,2] | etc.\n// With M the battery to prioritize\n\n// msg.batteries (original order)\nlet currentArray = msg.batteries;\n\n// Prioritize the Mth battery\nconst M = RED.util.getMessageProperty(msg,\"prioritize_battery\") || 1;\n\n// Flag wether the order may be reversed when discharging (e.g. Self-consumption utilizes this)\nconst reverseDischargePriority = RED.util.getMessageProperty(msg,\"battery_priority_cycle_interval\") !== \"Auto balance\";\nRED.util.setMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\", reverseDischargePriority,true);\n\n// 1st battery has prio? Skip and continue without change\nif (M==1) return msg;\n\n// N equals the number of items to take from the front to the back of the array, effectivly rotating battery priority\nconst N = M - 1; // base-0 version of M\nnode.status({fill:\"blue\",shape:\"ring\",text:`N = ${N}`});\n\n// 1. Check if the array is valid and long enough\nif (!Array.isArray(currentArray) || currentArray.length < N) {\n // Send an error or the original array back if the array is invalid/too short\n node.error(`Array is not valid or shorter than ${N} items.`, msg);\n return null;\n}\n\n// 2. Get the first N items (these will go to the back)\n// slice(0, N) retrieves elements from the start (index 0) up to index N (exclusive).\nlet firstN = currentArray.slice(0, N);\n\n// 3. Get the remaining items (these will stay at the front)\n// slice(N) retrieves elements from index N to the end of the array.\nlet remaining = currentArray.slice(N);\n\n// 4. Concatenate them: remaining items (front) + first N items (back)\nlet newArray = remaining.concat(firstN);\n\n// 5. Place the new array back into msg.batteries\nmsg.batteries = newArray;\n\nreturn msg;\n", + "func": "// Cycles battery priority as follows\n// M=1 [1,2,3,4] | M=2 [2,3,4,1] | M=3 [3,4,1,2] | etc.\n// With M the battery to prioritize\n\n// msg.batteries (original order)\nlet currentArray = msg.batteries;\n\n// Prioritize the Mth battery\nconst M = RED.util.getMessageProperty(msg,\"prioritize_battery\") || 1;\n\nif (Array.isArray(currentArray)) {\n currentArray.forEach((battery) => {\n if (battery && typeof battery === \"object\") battery.is_priority_battery = false;\n });\n\n const priorityBattery = currentArray[M - 1];\n if (priorityBattery && typeof priorityBattery === \"object\") {\n priorityBattery.is_priority_battery = true;\n RED.util.setMessageProperty(msg, \"priority_battery_id\", priorityBattery.id, true);\n }\n}\n\n// Flag wether the order may be reversed when discharging (e.g. Self-consumption utilizes this)\nconst reverseDischargePriority = RED.util.getMessageProperty(msg,\"battery_priority_cycle_interval\") !== \"Auto balance\";\nRED.util.setMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\", reverseDischargePriority,true);\n\n// 1st battery has prio? Skip and continue without change\nif (M==1) return msg;\n\n// N equals the number of items to take from the front to the back of the array, effectivly rotating battery priority\nconst N = M - 1; // base-0 version of M\nnode.status({fill:\"blue\",shape:\"ring\",text:`N = ${N}`});\n\n// 1. Check if the array is valid and long enough\nif (!Array.isArray(currentArray) || currentArray.length < N) {\n // Send an error or the original array back if the array is invalid/too short\n node.error(`Array is not valid or shorter than ${N} items.`, msg);\n return null;\n}\n\n// 2. Get the first N items (these will go to the back)\n// slice(0, N) retrieves elements from the start (index 0) up to index N (exclusive).\nlet firstN = currentArray.slice(0, N);\n\n// 3. Get the remaining items (these will stay at the front)\n// slice(N) retrieves elements from index N to the end of the array.\nlet remaining = currentArray.slice(N);\n\n// 4. Concatenate them: remaining items (front) + first N items (back)\nlet newArray = remaining.concat(firstN);\n\n// 5. Place the new array back into msg.batteries\nmsg.batteries = newArray;\n\nreturn msg;\n", "outputs": 1, "timeout": 0, "noerr": 0, @@ -4977,7 +4977,7 @@ "z": "419f395a5f52024b", "g": "0eb89bb2e8918ed0", "name": "Custom logger", - "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function allocatePriorityFirst(items, budget, getCapacity) {\n const allocations = new Map(items.map((item) => [item, 0]));\n let remainingBudget = Math.max(0, Math.floor(Number(budget) || 0));\n\n items.forEach((item) => {\n if (remainingBudget <= 0) return;\n\n const capacity = Math.max(0, Number(getCapacity(item)) || 0);\n const assignedPower = Math.min(capacity, remainingBudget);\n allocations.set(item, assignedPower);\n remainingBudget -= assignedPower;\n });\n\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {}, allocationMode = \"water-fill\" }) {\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => ({\n battery,\n phase: getPhase(battery),\n requestedPower: Math.max(0, Number(battery?.[maxPowerProperty]) || 0),\n assignedPower: 0,\n }));\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocator = allocationMode === \"priority-first\" ? allocatePriorityFirst : allocateWaterFill;\n const allocations = allocator(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, allocateWaterFill, allocatePriorityFirst, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", + "func": "/**\n * Custom Logger Function\n * * Provides centralized logging, node-status updates, and message tracing.\n * Specifically designed for Home Battery Control logic within Node-RED.\n * \n * Debug dashboard updating:\n * A msg needs to reach 'Strategy Evaluation' or be passed to the \"Show on debug board\" trigger node.\n * Errors are automatically passed to the \"Show on debug board\" trigger node.\n * \n * * @param {object} callingNode - The context of the calling node (pass 'this').\n * @param {string} text - The log message/explanation.\n * @param {string} level - Log level: 'log', 'warn', 'error', or 'info' (default: 'info').\n * \n * Note: anything above 'info' will also write to the Node-RED log files \n * \n * * @usage: \n * const log = global.get(\"logger\");\n * log(this, \"Charging cycle started\");\n */\nconst customLogger = (callingNode, text, level = 'info') => {\n \n // Log only when debug_mode is ON\n const debug_mode = global.get(\"debug_mode\") || false;\n if(debug_mode == false) return null;\n\n // Max log elements to keep\n const MAX_ELEMENTS = 100;\n\n // Create the YYYY-MM-DD HH:MM:SS.mmm format\n const now = new Date();\n const timestamp = now.getHours().toString().padStart(2, '0') + \":\" +\n now.getMinutes().toString().padStart(2, '0') + \":\" +\n now.getSeconds().toString().padStart(2, '0') + \".\" +\n now.getMilliseconds().toString().padStart(3, '0');\n\n const formattedTimeLevel = `${timestamp} [${level.toLowerCase()}]`;\n \n // Get the name of the node or fallback to the ID\n const nodeName = callingNode.__node__ ? callingNode.__node__.name || callingNode.__node__.id : undefined;\n // Get the name of the Flow/Tab\n const flowName = callingNode.env.get(\"NR_FLOW_NAME\") || \"Unknown flow\";\n // Explain to the Home Battery Control user what is going on\n const formattedExplenation = `[${flowName} >> ${nodeName}]: ${text}`;\n\n // Level icons\n let icon = \"⚪\";\n switch (level) {\n case \"info\":\n icon = \"ℹ️\";\n break;\n case \"log\":\n icon = \"🔧\";\n break;\n case \"warn\":\n icon = \"⚠️\"; \n break;\n case \"error\":\n icon = \"❌\";\n break;\n }\n\n // Enrich msg with log info\n const msg = callingNode.msg;\n (msg.log = msg.log || []).push({ timestamp: timestamp, level:level, flow:flowName, node:nodeName, payload: text, icon: icon});\n\n if (msg.log.length > MAX_ELEMENTS) {\n // Slice keeps the last X elements\n msg.log = msg.log.slice(-MAX_ELEMENTS);\n }\n\n // Log to the Node-RED system log\n switch (level) {\n case \"log\":\n node.log(`${formattedExplenation}`);\n break;\n case \"warn\":\n node.warn(`${formattedExplenation}`); \n break;\n case \"error\":\n node.error(`${formattedExplenation}`, callingNode.msg);\n break;\n default:\n // don't log to logfiles by default\n }\n\n};\n\nconst phasePowerAllocator = (() => {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const OBSERVED_CHARGE_HEADROOM_W = 100;\n const UNDERUSE_DETECTION_DELAY_MS = 10000;\n const UNDERUSE_MARGIN_W = 100;\n\n function getPhase(battery) {\n const phase = String(battery?.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n }\n\n function getModeFromMaxPowerProperty(maxPowerProperty) {\n return String(maxPowerProperty) === \"charging_max\" ? \"charge\" : \"discharge\";\n }\n\n function getObservedModePower(battery) {\n const power = Number(battery?.power);\n return Number.isFinite(power) ? Math.max(0, Math.abs(power)) : null;\n }\n\n function getLastCommand(battery) {\n return battery?.last_command || battery?.lastCommand || null;\n }\n\n function getResponsivePowerLimit(battery, maxPower, mode) {\n const requestedPower = Math.max(0, Number(maxPower) || 0);\n if (requestedPower <= 0) return 0;\n\n const soc = Number(battery?.soc);\n const socMax = Number(battery?.soc_max);\n const socMin = Number(battery?.soc_min);\n\n if (mode === \"charge\" && Number.isFinite(soc) && Number.isFinite(socMax) && soc >= socMax) {\n return 0;\n }\n if (mode === \"discharge\" && Number.isFinite(soc) && Number.isFinite(socMin) && soc <= socMin) {\n return 0;\n }\n if (mode !== \"charge\") {\n return requestedPower;\n }\n\n const observedPower = getObservedModePower(battery);\n const lastCommand = getLastCommand(battery);\n const lastMode = String(lastCommand?.mode || \"\").toLowerCase();\n const lastPower = Number(lastCommand?.power);\n if (observedPower === null || lastMode !== mode || !Number.isFinite(lastPower) || lastPower <= 0) {\n return requestedPower;\n }\n\n const since = Number(lastCommand?.since ?? lastCommand?.updated);\n const commandAgeMs = Number.isFinite(since) ? Math.max(0, Date.now() - since) : 0;\n const nearSocCeiling = Number.isFinite(soc) && Number.isFinite(socMax) && soc >= Math.max(0, socMax - 5);\n const sustainedUnderuse = commandAgeMs >= UNDERUSE_DETECTION_DELAY_MS\n && observedPower + UNDERUSE_MARGIN_W < Math.min(lastPower, requestedPower);\n\n if (!nearSocCeiling && !sustainedUnderuse) {\n return requestedPower;\n }\n\n return Math.min(requestedPower, Math.floor(observedPower + OBSERVED_CHARGE_HEADROOM_W));\n }\n\n function allocateWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n assignedFloat: 0,\n assignedPower: 0,\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n if (targetPower >= totalCapacity) {\n entries.forEach((entry) => allocations.set(entry.item, entry.capacity));\n return allocations;\n }\n\n let remainingBudget = targetPower;\n let remainingCount = entries.length;\n let level = 0;\n\n [...entries]\n .sort((a, b) => a.capacity - b.capacity)\n .some((entry) => {\n const candidateLevel = remainingBudget / remainingCount;\n if (candidateLevel <= entry.capacity) {\n level = candidateLevel;\n return true;\n }\n\n remainingBudget -= entry.capacity;\n remainingCount -= 1;\n return false;\n });\n\n entries.forEach((entry) => {\n entry.assignedFloat = Math.min(entry.capacity, level);\n entry.assignedPower = Math.floor(entry.assignedFloat);\n });\n\n const assignedTotal = entries.reduce((sum, entry) => sum + entry.assignedPower, 0);\n let leftover = targetPower - assignedTotal;\n\n const remainderOrder = [...entries]\n .filter((entry) => entry.assignedPower < entry.capacity)\n .sort((a, b) => {\n const fractionDiff = (b.assignedFloat - Math.floor(b.assignedFloat)) - (a.assignedFloat - Math.floor(a.assignedFloat));\n if (fractionDiff !== 0) return fractionDiff;\n\n const headroomDiff = (b.capacity - b.assignedPower) - (a.capacity - a.assignedPower);\n if (headroomDiff !== 0) return headroomDiff;\n\n return a.index - b.index;\n });\n\n let cursor = 0;\n while (leftover > 0 && remainderOrder.length > 0) {\n const entry = remainderOrder[cursor % remainderOrder.length];\n if (entry.assignedPower < entry.capacity) {\n entry.assignedPower += 1;\n leftover -= 1;\n }\n cursor += 1;\n }\n\n entries.forEach((entry) => allocations.set(entry.item, entry.assignedPower));\n return allocations;\n }\n\n function itemHasPriority(item) {\n if (Array.isArray(item?.entries)) {\n return item.entries.some((entry) => itemHasPriority(entry));\n }\n\n const battery = item?.battery || item?.state?.battery || item;\n return battery?.is_priority_battery === true;\n }\n\n function allocatePriorityWaterFill(items, budget, getCapacity) {\n const entries = items\n .map((item, index) => ({\n item,\n index,\n capacity: Math.max(0, Number(getCapacity(item)) || 0),\n }))\n .filter((entry) => entry.capacity > 0);\n\n const allocations = new Map(items.map((item) => [item, 0]));\n const totalCapacity = entries.reduce((sum, entry) => sum + entry.capacity, 0);\n const targetPower = Math.min(totalCapacity, Math.max(0, Math.floor(Number(budget) || 0)));\n\n if (entries.length === 0 || targetPower <= 0) {\n return allocations;\n }\n\n const priorityEntries = entries.filter((entry) => itemHasPriority(entry.item));\n if (priorityEntries.length === 0) {\n return allocateWaterFill(items, targetPower, getCapacity);\n }\n\n const regularEntries = entries.filter((entry) => !itemHasPriority(entry.item));\n const priorityCapacity = priorityEntries.reduce((sum, entry) => sum + entry.capacity, 0);\n const priorityBudget = Math.min(targetPower, priorityCapacity);\n const priorityAllocations = allocateWaterFill(priorityEntries, priorityBudget, (entry) => entry.capacity);\n let assignedPriorityPower = 0;\n\n priorityEntries.forEach((entry) => {\n const assignedPower = priorityAllocations.get(entry) || 0;\n allocations.set(entry.item, assignedPower);\n assignedPriorityPower += assignedPower;\n });\n\n const regularBudget = targetPower - assignedPriorityPower;\n if (regularBudget <= 0 || regularEntries.length === 0) {\n return allocations;\n }\n\n const regularAllocations = allocateWaterFill(regularEntries, regularBudget, (entry) => entry.capacity);\n regularEntries.forEach((entry) => {\n allocations.set(entry.item, regularAllocations.get(entry) || 0);\n });\n\n return allocations;\n }\n\n function buildMaxPowerStates({ batteries, maxPowerProperty, phaseLimits = {}, allocationMode = \"water-fill\" }) {\n const mode = getModeFromMaxPowerProperty(maxPowerProperty);\n const batteryStates = (Array.isArray(batteries) ? batteries : []).map((battery) => {\n const maxPower = Math.max(0, Number(battery?.[maxPowerProperty]) || 0);\n return {\n battery,\n phase: getPhase(battery),\n requestedPower: getResponsivePowerLimit(battery, maxPower, mode),\n assignedPower: 0,\n };\n });\n\n const statesByPhase = batteryStates.reduce((groups, state) => {\n if (!groups[state.phase]) groups[state.phase] = [];\n groups[state.phase].push(state);\n return groups;\n }, {});\n\n Object.entries(statesByPhase).forEach(([phase, phaseStates]) => {\n const requestedPower = phaseStates.reduce((sum, state) => sum + state.requestedPower, 0);\n const phaseLimit = PHASES.includes(phase) ? Number(phaseLimits[phase]) : Infinity;\n const phaseBudget = Number.isFinite(phaseLimit) ? Math.max(0, phaseLimit) : requestedPower;\n const allocator = allocationMode === \"priority-first\" ? allocatePriorityWaterFill : allocateWaterFill;\n const allocations = allocator(phaseStates, phaseBudget, (state) => state.requestedPower);\n\n phaseStates.forEach((state) => {\n state.assignedPower = allocations.get(state) || 0;\n });\n });\n\n return batteryStates;\n }\n\n return { phases: PHASES, getPhase, getResponsivePowerLimit, allocateWaterFill, allocatePriorityWaterFill, allocatePriorityFirst: allocatePriorityWaterFill, buildMaxPowerStates };\n})();\n\nglobal.set('phasePowerAllocator', phasePowerAllocator);\n\nglobal.set('logger', customLogger);\nglobal.set('unhandledException', \"\");\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, @@ -13248,7 +13248,7 @@ "z": "489c548637b42621", "g": "0d27c0ba873a6bdd", "name": "Load distribution", - "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseAllocator = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst usePhaseWaterFill = usePhaseAllocator && !isCharging && typeof phaseAllocator.allocateWaterFill === \"function\";\nconst usePhasePriorityFirst = usePhaseAllocator && isCharging && typeof phaseAllocator.allocatePriorityFirst === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const assignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWithAllocator(candidateStates, requestedPower, allocator) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = allocator(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = allocator(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhasePriorityFirst) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocatePriorityFirst);\n }\n if (usePhaseWaterFill) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocateWaterFill);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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", + "func": "// Logger\nconst log = global.get(\"logger\");\nlog(this,\"**PID load distribution**\");\n\n// INPUT\nvar batteries = Array.isArray(msg.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 = RED.util.getMessageProperty(msg, \"advanced_settings.reverse_discharge_priority\") ?? false; // Prioritize (dis)charging the same battery\nconst isDischargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.discharge_disabled\"); // Disallow discharging?\nconst isChargeDisabled = RED.util.getMessageProperty(msg,\"advanced_settings.charge_disabled\"); // Disallow charging?\nconst phaseProtection = RED.util.getMessageProperty(msg, \"phase_protection\") || {};\nconst phaseProtectionActive = phaseProtection.active === true && [\"import\", \"export\"].includes(phaseProtection.direction);\nconst phaseDirectionMatches = (phaseProtection.direction === \"import\" && !isCharging) || (phaseProtection.direction === \"export\" && isCharging);\nconst phaseRequirements = phaseProtection.required_by_phase || {};\nconst phaseAggregateResidual = Math.max(0, Number(phaseProtection.aggregate_residual_power) || 0);\nconst phaseCommandLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase\") || {};\nconst phaseCommandLimitRemaining = {\n charge: { ...(phaseCommandLimits.charge || {}) },\n discharge: { ...(phaseCommandLimits.discharge || {}) },\n};\nconst phaseAllocator = global.get(\"phasePowerAllocator\") || {};\nconst PHASES = Array.isArray(phaseAllocator.phases) ? phaseAllocator.phases : [\"L1\", \"L2\", \"L3\"];\nconst usePhaseAllocator = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst usePhaseWaterFill = usePhaseAllocator && !isCharging && typeof phaseAllocator.allocateWaterFill === \"function\";\nconst usePhasePriorityFirst = usePhaseAllocator && isCharging && typeof phaseAllocator.allocatePriorityWaterFill === \"function\";\n\n// how much power do the batteries need to compensate?\nconst unassigned_power_initial = Math.round(Math.abs(msg.payload)); // Power in W to compensate using batteries\nlet remaining_output_power = unassigned_power_initial;\nlog(this,`How much power do the batteries need to compensate: ${unassigned_power_initial} W`);\n\n// inits\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\nconst phaseAssignment = { L1: { required: 0, assigned: 0, unassigned: 0 }, L2: { required: 0, assigned: 0, unassigned: 0 }, L3: { required: 0, assigned: 0, unassigned: 0 } };\nlet aggregateAssignedPower = 0;\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, Number(max_power) || 0);\n\n // Limit charge at high SoC\n if (hasChargingLimiter) {\n if (soc >= 99) limitedPower = Math.min(1000, limitedPower);\n else if (soc >= 95) 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\nfunction getPhase(battery) {\n if (typeof phaseAllocator.getPhase === \"function\") return phaseAllocator.getPhase(battery);\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n}\n\nfunction getPhaseCommandLimitValue(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimits[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction getPhaseCommandLimitRemaining(phase) {\n if (!PHASES.includes(phase)) return Infinity;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n return Number.isFinite(value) ? Math.max(0, value) : Infinity;\n}\n\nfunction usePhaseCommandLimit(phase, assignedPower) {\n if (!PHASES.includes(phase) || assignedPower <= 0) return;\n\n const direction = isCharging ? \"charge\" : \"discharge\";\n const value = Number(phaseCommandLimitRemaining[direction]?.[phase]);\n if (!Number.isFinite(value)) return;\n\n phaseCommandLimitRemaining[direction][phase] = Math.max(0, value - assignedPower);\n}\n\nfunction isFiniteNumber(value) {\n return Number.isFinite(Number(value));\n}\n\nfunction getUnavailableFields(battery) {\n const unavailableFields = [];\n const socLimitName = isCharging ? \"max SoC\" : \"min SoC\";\n const socLimit = isCharging ? battery.soc_max : battery.soc_min;\n const powerLimitName = isCharging ? \"max charge power\" : \"max discharge power\";\n const powerLimit = isCharging ? battery.charging_max : battery.discharging_max;\n\n if (!isFiniteNumber(battery.soc)) unavailableFields.push(\"SoC\");\n if (!isFiniteNumber(socLimit)) unavailableFields.push(socLimitName);\n if (!isFiniteNumber(powerLimit)) unavailableFields.push(powerLimitName);\n\n return unavailableFields;\n}\n\nfunction getSkipReason(battery) {\n if (battery.rs485 !== \"enable\") {\n return `Battery ${battery.id} [SKIP]: skipped because RS485 is not 'enable'`;\n }\n\n const unavailableFields = getUnavailableFields(battery);\n if (unavailableFields.length > 0) {\n return `Battery ${battery.id} [SKIP]: skipped because required telemetry is unavailable: ${unavailableFields.join(\", \")}`;\n }\n\n if (isCharging && battery.soc >= battery.soc_max) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) >= Max (${battery.soc_max}%) while charging`;\n }\n if (!isCharging && battery.soc <= battery.soc_min) {\n return `Battery ${battery.id} [SKIP]: skipped because SoC (${battery.soc}%) <= Min (${battery.soc_min}%) while discharging`;\n }\n if (!isCharging && isDischargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, discharging was disabled via msg property`;\n }\n if (isCharging && isChargeDisabled) {\n return `Battery ${battery.id} [SKIP]: skipped, charging was disabled via msg property`;\n }\n return null;\n}\n\n// -- BATTERY DISCHARGE PRIORITY\nif (!isCharging && hasReverseDischargePriority) {\n batteries.reverse();\n}\n\nconst phaseAssignablePower = { L1: 0, L2: 0, L3: 0, unassigned: 0 };\n\nconst batteryStates = batteries.map((battery) => {\n // Explain\n log(this, `Battery ${battery.id}: ${battery.soc}% (min: ${battery.soc_min}%, max: ${battery.soc_max}%) ; Phase: ${getPhase(battery)} ; RS485: ${battery.rs485=='enable'?'OK':'Nok'}`);\n\n const phase = getPhase(battery);\n const skipReason = getSkipReason(battery);\n const maxAssignablePower = skipReason === null\n ? (isCharging ? chargingLimiter(battery.soc, battery.charging_max) : Math.max(0, Number(battery.discharging_max) || 0))\n : 0;\n const assignablePower = skipReason === null && isCharging && typeof phaseAllocator.getResponsivePowerLimit === \"function\"\n ? phaseAllocator.getResponsivePowerLimit(battery, maxAssignablePower, \"charge\")\n : maxAssignablePower;\n\n phaseAssignablePower[phase] += Number(assignablePower);\n\n return {\n battery,\n phase,\n skipReason,\n assignablePower,\n assignedPower: 0,\n };\n});\n\nbatteries_total_assignable_power = Object.entries(phaseAssignablePower).reduce((sum, [phase, power]) => {\n const phaseLimit = getPhaseCommandLimitValue(phase);\n return sum + Math.min(Number(power) || 0, phaseLimit);\n}, 0);\n\nfunction assignPowerFirstFit(state, requestedPower) {\n if (remaining_output_power <= 0 || requestedPower <= 0 || state.skipReason !== null) return 0;\n\n const remainingBatteryPower = Math.max(0, state.assignablePower - state.assignedPower);\n const phaseLimitPower = getPhaseCommandLimitRemaining(state.phase);\n const assignedPower = Math.min(requestedPower, remainingBatteryPower, remaining_output_power, phaseLimitPower);\n if (assignedPower <= 0) return 0;\n\n state.assignedPower += assignedPower;\n remaining_output_power -= assignedPower;\n usePhaseCommandLimit(state.phase, assignedPower);\n return assignedPower;\n}\n\nfunction assignPowerWithAllocator(candidateStates, requestedPower, allocator) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n if (remaining_output_power <= 0 || requested <= 0) return { assigned: 0, unassigned: requested };\n\n const eligibleStates = candidateStates\n .filter((state) => state.skipReason === null)\n .map((state) => ({\n state,\n availablePower: Math.max(0, state.assignablePower - state.assignedPower),\n }))\n .filter((entry) => entry.availablePower > 0);\n\n if (eligibleStates.length === 0) return { assigned: 0, unassigned: requested };\n\n const statesByPhase = eligibleStates.reduce((groups, entry) => {\n if (!groups[entry.state.phase]) groups[entry.state.phase] = [];\n groups[entry.state.phase].push(entry);\n return groups;\n }, {});\n\n const phaseGroups = Object.entries(statesByPhase)\n .map(([phase, entries]) => {\n const batteryCapacity = entries.reduce((sum, entry) => sum + entry.availablePower, 0);\n const phaseCapacity = Math.min(batteryCapacity, getPhaseCommandLimitRemaining(phase));\n return { phase, entries, capacity: phaseCapacity };\n })\n .filter((group) => group.capacity > 0);\n\n if (phaseGroups.length === 0) return { assigned: 0, unassigned: requested };\n\n const targetPower = Math.min(requested, remaining_output_power);\n const phaseAllocations = allocator(phaseGroups, targetPower, (group) => group.capacity);\n let assignedTotal = 0;\n\n phaseGroups.forEach((group) => {\n const phaseAssignedPower = phaseAllocations.get(group) || 0;\n if (phaseAssignedPower <= 0) return;\n\n const batteryAllocations = allocator(group.entries, phaseAssignedPower, (entry) => entry.availablePower);\n let assignedOnPhase = 0;\n\n group.entries.forEach((entry) => {\n const assignedPower = batteryAllocations.get(entry) || 0;\n if (assignedPower <= 0) return;\n\n entry.state.assignedPower += assignedPower;\n assignedOnPhase += assignedPower;\n assignedTotal += assignedPower;\n });\n\n usePhaseCommandLimit(group.phase, assignedOnPhase);\n });\n\n remaining_output_power -= assignedTotal;\n return { assigned: assignedTotal, unassigned: Math.max(0, requested - assignedTotal) };\n}\n\nfunction assignPower(candidateStates, requestedPower) {\n const requested = Math.max(0, Number(requestedPower) || 0);\n\n if (usePhasePriorityFirst) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocatePriorityWaterFill);\n }\n if (usePhaseWaterFill) {\n return assignPowerWithAllocator(candidateStates, requested, phaseAllocator.allocateWaterFill);\n }\n\n let assigned = 0;\n candidateStates.forEach((state) => {\n const assignedPower = assignPowerFirstFit(state, requested - assigned);\n assigned += assignedPower;\n });\n\n return { assigned, unassigned: Math.max(0, requested - assigned) };\n}\n\n// Phase-specific assignment first. This prevents a phase-protection request from\n// being consumed by batteries on a phase that cannot correct the violation.\nif (phaseProtectionActive && phaseDirectionMatches) {\n PHASES.forEach((phase) => {\n const phaseRequiredPower = Math.max(0, Math.round(Number(phaseRequirements[phase]) || 0));\n phaseAssignment[phase].required = phaseRequiredPower;\n\n if (phaseRequiredPower <= 0) return;\n\n log(this, `Phase protection ${phase}: assigning ${phaseRequiredPower} W`);\n const allocation = assignPower(\n batteryStates.filter((state) => state.phase === phase),\n phaseRequiredPower\n );\n\n phaseAssignment[phase].assigned += allocation.assigned;\n phaseAssignment[phase].unassigned = allocation.unassigned;\n if (allocation.unassigned > 0) {\n log(this, `Phase protection ${phase}: ${allocation.unassigned} W could not be assigned to batteries on this phase`, \"warn\");\n }\n });\n}\n\n// Aggregate residual assignment second. During phase protection this is only the\n// part of the requested output that is not already reserved for phase correction.\nlet aggregatePowerToAssign = phaseProtectionActive && phaseDirectionMatches\n ? Math.min(phaseAggregateResidual, remaining_output_power)\n : remaining_output_power;\n\nconst aggregateAllocation = assignPower(batteryStates, aggregatePowerToAssign);\naggregateAssignedPower += aggregateAllocation.assigned;\n\n// Build solutions after all phase and aggregate assignments are known.\nconst solution_array = batteryStates.map((state) => {\n if (state.skipReason !== null) {\n log(this, state.skipReason);\n return getStopSolution(state.battery.id);\n }\n\n let solution;\n if (state.assignedPower <= 0) {\n solution = getStopSolution(state.battery.id);\n } else {\n solution = getActiveSolution(state.battery.id, state.assignedPower);\n }\n\n // Explain: charge limiting\n if(isCharging && state.assignablePower < state.battery.charging_max) {\n log(this,`Charging limited to protect battery (${state.battery.soc}%): ${state.battery.charging_max}W -> ${state.assignablePower}W`);\n }\n // Explain: solution result\n log(this, `Battery ${solution.id}: assigned to ${solution.mode} with ${solution.power}W / ${isCharging ? state.battery.charging_max : state.battery.discharging_max }W max.`)\n\n return solution;\n});\n\n// battery discharge priority - put solutions back in initial order\nif (!isCharging && hasReverseDischargePriority) {\n solution_array.reverse();\n batteries.reverse();\n}\n\nconst assignedTotalPower = batteryStates.reduce((sum, state) => sum + Math.max(0, Number(state.assignedPower) || 0), 0);\nconst unassignedPower = Math.max(0, unassigned_power_initial - assignedTotalPower);\n\nif (phaseProtectionActive && phaseDirectionMatches) {\n RED.util.setMessageProperty(msg, \"phase_protection.assignment\", {\n by_phase: phaseAssignment,\n aggregate_assigned_power: Math.round(aggregateAssignedPower),\n }, true);\n}\n\n// store solutions in msg\nRED.util.setMessageProperty(msg, \"solutions\", solution_array,true);\n// store remaining load distribution results and PID_OUPUT in msg\nRED.util.setMessageProperty(msg, \"pid.output\", unassigned_power_initial, true); // the dampenend & filtered PID_output equals the load requirement\nRED.util.setMessageProperty(msg, \"pid.load\", unassigned_power_initial, true);\nRED.util.setMessageProperty(msg, \"pid.load_capacity\", batteries_total_assignable_power, true);\nRED.util.setMessageProperty(msg, \"pid.load_assigned\", parseInt(assignedTotalPower), true);\nRED.util.setMessageProperty(msg, \"pid.load_unassigned\", unassignedPower,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, From faab292c216165bc8682efa15a82753a60ce8015 Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Mon, 15 Jun 2026 09:30:24 +0200 Subject: [PATCH 12/14] test: add Node-RED flow harness and strategy integration tests --- .gitignore | 9 +- Makefile | 13 + package-lock.json | 245 ++++++++++++ package.json | 17 + test/fixtures/expectations.js | 25 ++ test/fixtures/homes.js | 102 +++++ test/fixtures/phase-states.js | 46 +++ test/integration/strategy-charge.test.js | 93 +++++ test/integration/strategy-full-stop.test.js | 30 ++ test/integration/strategy-partials.test.js | 107 ++++++ .../strategy-self-consumption.test.js | 85 +++++ test/integration/strategy-sell.test.js | 102 +++++ test/lib/context-store.js | 35 ++ test/lib/flow-graph.js | 352 ++++++++++++++++++ test/lib/function-runner.js | 119 ++++++ test/lib/ha-state-mock.js | 51 +++ test/lib/red-util.js | 3 + test/support/init-flow.js | 41 ++ test/unit/phase/command-limits.test.js | 94 +++++ test/unit/phase/protection-guard.test.js | 110 ++++++ test/unit/strategies/full-stop.test.js | 33 ++ 21 files changed, 1711 insertions(+), 1 deletion(-) create mode 100644 Makefile create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 test/fixtures/expectations.js create mode 100644 test/fixtures/homes.js create mode 100644 test/fixtures/phase-states.js create mode 100644 test/integration/strategy-charge.test.js create mode 100644 test/integration/strategy-full-stop.test.js create mode 100644 test/integration/strategy-partials.test.js create mode 100644 test/integration/strategy-self-consumption.test.js create mode 100644 test/integration/strategy-sell.test.js create mode 100644 test/lib/context-store.js create mode 100644 test/lib/flow-graph.js create mode 100644 test/lib/function-runner.js create mode 100644 test/lib/ha-state-mock.js create mode 100644 test/lib/red-util.js create mode 100644 test/support/init-flow.js create mode 100644 test/unit/phase/command-limits.test.js create mode 100644 test/unit/phase/protection-guard.test.js create mode 100644 test/unit/strategies/full-stop.test.js diff --git a/.gitignore b/.gitignore index 57472ed..adf601a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,16 @@ notes/ template_sensor_bob_battery_dashboard.yaml .DS_Store -# Ignore SASS cache files +# Ignore opencode local workspace +.opencode/ + docs/.sass-cache +# Node.js dependencies and test output +node_modules/ + +coverage/ + # Claude Code per-user state — keep local; commands/skills/agents/settings.json are shared .claude/settings.local.json .claude/sessions/ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..4bc523d --- /dev/null +++ b/Makefile @@ -0,0 +1,13 @@ +.PHONY: setup test test-unit test-integration + +setup: + npm ci + +test: + npm test + +test-unit: + npm run test:unit + +test-integration: + npm run test:integration diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..1254c9f --- /dev/null +++ b/package-lock.json @@ -0,0 +1,245 @@ +{ + "name": "marstek-venus-rs485-node-red-tests", + "version": "0.0.1", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "marstek-venus-rs485-node-red-tests", + "version": "0.0.1", + "devDependencies": { + "@node-red/util": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@node-red/util": { + "version": "4.1.11", + "resolved": "https://registry.npmjs.org/@node-red/util/-/util-4.1.11.tgz", + "integrity": "sha512-+rYoiuRkB9f5RFs1Rz1HNo9fC8lIAPrW1mIkDXCoTjAD/iDjAth7BRxcOSB1JCXpbV+ncj9Ger40S+UgVAUVRg==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "chalk": "^4.1.2", + "fs-extra": "11.3.0", + "i18next": "25.8.14", + "json-stringify-safe": "5.0.1", + "jsonata": "2.0.6", + "lodash.clonedeep": "^4.5.0", + "moment": "2.30.1", + "moment-timezone": "0.5.48" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fs-extra": { + "version": "11.3.0", + "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.0.tgz", + "integrity": "sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=14.14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/i18next": { + "version": "25.8.14", + "resolved": "https://registry.npmjs.org/i18next/-/i18next-25.8.14.tgz", + "integrity": "sha512-paMUYkfWJMsWPeE/Hejcw+XLhHrQPehem+4wMo+uELnvIwvCG019L9sAIljwjCmEMtFQQO3YeitJY8Kctei3iA==", + "dev": true, + "funding": [ + { + "type": "individual", + "url": "https://locize.com" + }, + { + "type": "individual", + "url": "https://locize.com/i18next.html" + }, + { + "type": "individual", + "url": "https://www.i18next.com/how-to/faq#i18next-is-awesome.-how-can-i-support-the-project" + } + ], + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.28.4" + }, + "peerDependencies": { + "typescript": "^5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/json-stringify-safe": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz", + "integrity": "sha512-ZClg6AaYvamvYEE82d3Iyd3vSSIjQ+odgjaTzRuO3s7toCdFKczob2i0zCh7JE8kWn17yvAWhUVxvqGwUalsRA==", + "dev": true, + "license": "ISC" + }, + "node_modules/jsonata": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/jsonata/-/jsonata-2.0.6.tgz", + "integrity": "sha512-WhQB5tXQ32qjkx2GYHFw2XbL90u+LLzjofAYwi+86g6SyZeXHz9F1Q0amy3dWRYczshOC3Haok9J4pOCgHtwyQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz", + "integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/moment": { + "version": "2.30.1", + "resolved": "https://registry.npmjs.org/moment/-/moment-2.30.1.tgz", + "integrity": "sha512-uEmtNhbDOrWPFS+hdjFCBfy9f2YoyzRpwcl+DqpC6taX21FzsTLQVbMV/W7PzNSX6x/bhC1zA3c2UQ5NzH6how==", + "dev": true, + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/moment-timezone": { + "version": "0.5.48", + "resolved": "https://registry.npmjs.org/moment-timezone/-/moment-timezone-0.5.48.tgz", + "integrity": "sha512-f22b8LV1gbTO2ms2j2z13MuPogNoh5UzxL3nzNAYKGraILnbGc9NEE6dyiiiLv46DGRb8A4kg8UKWLjPthxBHw==", + "dev": true, + "license": "MIT", + "dependencies": { + "moment": "^2.29.4" + }, + "engines": { + "node": "*" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/universalify": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz", + "integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..cd7b267 --- /dev/null +++ b/package.json @@ -0,0 +1,17 @@ +{ + "name": "marstek-venus-rs485-node-red-tests", + "version": "0.0.1", + "private": true, + "description": "Local unit and integration tests for Home Battery Control Node-RED strategies", + "scripts": { + "test": "node --test test/**/*.test.js", + "test:unit": "node --test test/unit/**/*.test.js", + "test:integration": "node --test test/integration/**/*.test.js" + }, + "devDependencies": { + "@node-red/util": "^4.0.0" + }, + "engines": { + "node": ">=20.0.0" + } +} diff --git a/test/fixtures/expectations.js b/test/fixtures/expectations.js new file mode 100644 index 0000000..58d2cb7 --- /dev/null +++ b/test/fixtures/expectations.js @@ -0,0 +1,25 @@ +'use strict'; + +function expectSolutions(actual, expectedById) { + if (!Array.isArray(actual)) { + throw new Error(`expected solutions array, got ${typeof actual}`); + } + const byId = Object.fromEntries(actual.map((s) => [s.id, s])); + for (const [id, expected] of Object.entries(expectedById)) { + const solution = byId[id]; + if (!solution) { + throw new Error(`expected solution for ${id} but not found`); + } + if (expected.mode !== undefined && solution.mode !== expected.mode) { + throw new Error(`expected ${id} mode ${expected.mode}, got ${solution.mode}`); + } + if (expected.power !== undefined && solution.power !== expected.power) { + throw new Error(`expected ${id} power ${expected.power}, got ${solution.power}`); + } + } + if (actual.length !== Object.keys(expectedById).length) { + throw new Error(`expected ${Object.keys(expectedById).length} solutions, got ${actual.length}`); + } +} + +module.exports = { expectSolutions }; diff --git a/test/fixtures/homes.js b/test/fixtures/homes.js new file mode 100644 index 0000000..4da829c --- /dev/null +++ b/test/fixtures/homes.js @@ -0,0 +1,102 @@ +'use strict'; + +const DEFAULTS = { + venusE: { + capacityKwh: 5, + chargeMaxW: 2500, + dischargeMaxW: 2500, + }, + venusA: { + capacityKwh: 12.46, + chargeMaxW: 1500, + dischargeMaxW: 1500, + }, +}; + +function makeBattery({ + id, + phase = 'unassigned', + soc = 50, + socMin = 10, + socMax = 100, + chargeMaxW = DEFAULTS.venusE.chargeMaxW, + dischargeMaxW = DEFAULTS.venusE.dischargeMaxW, + energyMaxKwh = DEFAULTS.venusE.capacityKwh, + power = 0, + rs485 = 'enable', + lastCommand = null, +}) { + return { + id, + phase, + power, + charging_max: chargeMaxW, + discharging_max: dischargeMaxW, + soc, + soc_max: socMax, + soc_min: socMin, + inverter: 'on', + energy: Number(((energyMaxKwh * soc) / 100).toFixed(3)), + energy_max: energyMaxKwh, + rs485, + last_command: lastCommand, + }; +} + +function singleVenusE(overrides = {}) { + return [makeBattery({ id: 'M1', ...DEFAULTS.venusE, ...overrides })]; +} + +function singleVenusEThrottled(overrides = {}) { + return [ + makeBattery({ + id: 'M1', + chargeMaxW: 2500, + dischargeMaxW: 800, + ...overrides, + }), + ]; +} + +function oneVenusEPerPhase(overridesPerBattery = []) { + return ['L1', 'L2', 'L3'].map((phase, index) => + makeBattery({ + id: `M${index + 1}`, + phase, + ...(overridesPerBattery[index] || {}), + }) + ); +} + +function twoVenusEPerPhase(overridesPerBattery = []) { + const phases = ['L1', 'L1', 'L2', 'L2', 'L3', 'L3']; + return phases.map((phase, index) => + makeBattery({ + id: `M${index + 1}`, + phase, + ...(overridesPerBattery[index] || {}), + }) + ); +} + +function heterogeneousSystem(overridesPerBattery = []) { + const definitions = [ + { id: 'M1', phase: 'L3', chargeMaxW: 2200, dischargeMaxW: 2500 }, + { id: 'M2', phase: 'L3', chargeMaxW: 1750, dischargeMaxW: 1750 }, + { id: 'M3', phase: 'L2' }, + { id: 'M4', phase: 'L1' }, + { id: 'M5', phase: 'L3', capacityKwh: 12.46, chargeMaxW: 1500, dischargeMaxW: 1500 }, + ]; + return definitions.map((def, index) => + makeBattery({ ...def, ...(overridesPerBattery[index] || {}) }) + ); +} + +module.exports = { + makeBattery, + singleVenusE, + singleVenusEThrottled, + oneVenusEPerPhase, + twoVenusEPerPhase, + heterogeneousSystem, +}; diff --git a/test/fixtures/phase-states.js b/test/fixtures/phase-states.js new file mode 100644 index 0000000..1ca06b5 --- /dev/null +++ b/test/fixtures/phase-states.js @@ -0,0 +1,46 @@ +'use strict'; + +function phasePower(readings) { + return { + L1: readings.L1 ?? null, + L2: readings.L2 ?? null, + L3: readings.L3 ?? null, + }; +} + +function phaseBatteryPower(readings) { + return { + signed: { L1: 0, L2: 0, L3: 0 }, + charge: { L1: 0, L2: 0, L3: 0 }, + discharge: { L1: 0, L2: 0, L3: 0 }, + ...readings, + }; +} + +function phaseProtectionOptions(options = {}) { + return { + enabled: true, + available: options.available ?? true, + active: options.active ?? false, + direction: options.direction ?? null, + limit: options.limit ?? 5500, + sensors_available: options.sensors_available ?? true, + assignments_available: options.assignments_available ?? true, + assigned_phases: options.assigned_phases ?? ['L1', 'L2', 'L3'], + active_phases: options.active_phases ?? [], + required_by_phase: { L1: 0, L2: 0, L3: 0 }, + required_phase_power: 0, + aggregate_required_power: 0, + aggregate_residual_power: 0, + required_total_power: 0, + target_grid_consumption_in_w: null, + unassigned_violations: [], + ...options, + }; +} + +module.exports = { + phasePower, + phaseBatteryPower, + phaseProtectionOptions, +}; diff --git a/test/integration/strategy-charge.test.js b/test/integration/strategy-charge.test.js new file mode 100644 index 0000000..4fd2301 --- /dev/null +++ b/test/integration/strategy-charge.test.js @@ -0,0 +1,93 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { FlowGraph } = require('../lib/flow-graph'); +const { Initializer } = require('../support/init-flow'); +const { ContextStore } = require('../lib/context-store'); +const { StateProvider } = require('../lib/ha-state-mock'); +const { twoVenusEPerPhase, singleVenusEThrottled } = require('../fixtures/homes'); + +describe('Charge strategy integration', () => { + it('charges at max power without limits', async () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const graph = new FlowGraph({ + context: new ContextStore(), + flow: new ContextStore(), + global: initializer.global, + }); + graph.load(['node-red/02 strategy-charge.json']); + + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ['input_select.house_battery_strategy_charge_goal', 'batteries are full'], + ]); + + const msg = { + batteries: singleVenusEThrottled(), + grid_power_has_limit_import: false, + phase_protection: { + enabled: false, + command_limits_available: false, + command_limit_by_phase: { + charge: { L1: null, L2: null, L3: null }, + discharge: { L1: null, L2: null, L3: null }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Charge', msg); + + assert.equal(terminals.length, 1); + assert.deepEqual(terminals[0].solutions, [ + { id: 'M1', mode: 'charge', power: 2500 }, + ]); + }); + + it('throttles max-power charge when phase command limits are lower', async () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const graph = new FlowGraph({ + context: new ContextStore(), + flow: new ContextStore(), + global: initializer.global, + }); + graph.load(['node-red/02 strategy-charge.json']); + + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ['input_select.house_battery_strategy_charge_goal', 'batteries are full'], + ]); + + const msg = { + batteries: twoVenusEPerPhase(), + grid_power_has_limit_import: false, + phase_protection: { + enabled: false, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 3000, L2: 3000, L3: 3000 }, + discharge: { L1: null, L2: null, L3: null }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Charge', msg); + + assert.equal(terminals.length, 1); + // First-fit allocation on each phase: each battery wants 2500W; phase limit 3000 + // first battery gets 2500, second gets remaining 500. + const solutions = terminals[0].solutions; + assert.equal(solutions[0].power, 2500); + assert.equal(solutions[1].power, 500); + assert.equal(solutions[2].power, 2500); + assert.equal(solutions[3].power, 500); + assert.equal(solutions[4].power, 2500); + assert.equal(solutions[5].power, 500); + }); +}); diff --git a/test/integration/strategy-full-stop.test.js b/test/integration/strategy-full-stop.test.js new file mode 100644 index 0000000..0bc4820 --- /dev/null +++ b/test/integration/strategy-full-stop.test.js @@ -0,0 +1,30 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { FlowGraph } = require('../lib/flow-graph'); +const { Initializer } = require('../support/init-flow'); +const { ContextStore } = require('../lib/context-store'); +const { singleVenusE } = require('../fixtures/homes'); + +describe('Full stop strategy integration', () => { + it('walks from link in to link out and stops every battery', async () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const context = new ContextStore(); + const flow = new ContextStore(); + const globalStore = initializer.global; + + const graph = new FlowGraph({ context, flow, global: globalStore }); + graph.load(['node-red/02 strategy-full-stop.json']); + + const msg = { batteries: singleVenusE() }; + const terminals = await graph.run('Full stop', msg); + + assert.equal(terminals.length, 1); + assert.deepEqual(terminals[0].solutions, [ + { id: 'M1', mode: 'stop', power: 0 }, + ]); + }); +}); diff --git a/test/integration/strategy-partials.test.js b/test/integration/strategy-partials.test.js new file mode 100644 index 0000000..7456611 --- /dev/null +++ b/test/integration/strategy-partials.test.js @@ -0,0 +1,107 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { FlowGraph } = require('../lib/flow-graph'); +const { Initializer } = require('../support/init-flow'); +const { ContextStore } = require('../lib/context-store'); +const { StateProvider } = require('../lib/ha-state-mock'); +const { oneVenusEPerPhase } = require('../fixtures/homes'); + +describe('Partials strategy integration', () => { + it('peak shaves import by discharging when grid power exceeds import limit', async () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const graph = new FlowGraph({ + context: new ContextStore(), + flow: new ContextStore(), + global: initializer.global, + }); + + graph.load([ + 'node-red/02 strategy-partials.json', + 'node-red/02 strategy-self-consumption.json', + 'node-red/02 strategy-full-stop.json', + ]); + + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'on'], + ['input_number.house_battery_grid_power_limit_import', '2500'], + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ]); + + const msg = { + target: 'Standby / peak shave', + batteries: oneVenusEPerPhase([{ soc: 90 }, { soc: 90 }, { soc: 90 }]), + grid_power: 5000, + grid_power_limit_import: 2500, + grid_power_has_limit_import: true, + grid_power_has_limit_export: false, + advanced_settings: {}, + phase_protection: { + enabled: false, + command_limits_available: false, + command_limit_by_phase: { + charge: { L1: null, L2: null, L3: null }, + discharge: { L1: null, L2: null, L3: null }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Standby / peak shave', msg); + + assert.equal(terminals.length, 1); + assert.equal(terminals[0].solutions.length, 3); + assert.ok(terminals[0].solutions.every((s) => s.mode === 'discharge' && s.power > 0)); + }); + + it('peak shaves export by charging when grid power exceeds export limit', async () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const graph = new FlowGraph({ + context: new ContextStore(), + flow: new ContextStore(), + global: initializer.global, + }); + + graph.load([ + 'node-red/02 strategy-partials.json', + 'node-red/02 strategy-self-consumption.json', + 'node-red/02 strategy-full-stop.json', + ]); + + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_export', 'on'], + ['input_number.house_battery_grid_power_limit_export', '2500'], + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ]); + + const msg = { + target: 'Standby / peak shave', + batteries: oneVenusEPerPhase([{ soc: 50 }, { soc: 50 }, { soc: 50 }]), + grid_power: -5000, + grid_power_limit_export: 2500, + grid_power_has_limit_import: false, + grid_power_has_limit_export: true, + advanced_settings: {}, + phase_protection: { + enabled: false, + command_limits_available: false, + command_limit_by_phase: { + charge: { L1: null, L2: null, L3: null }, + discharge: { L1: null, L2: null, L3: null }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Standby / peak shave', msg); + + assert.equal(terminals.length, 1); + assert.equal(terminals[0].solutions.length, 3); + assert.ok(terminals[0].solutions.every((s) => s.mode === 'charge' && s.power > 0)); + }); +}); diff --git a/test/integration/strategy-self-consumption.test.js b/test/integration/strategy-self-consumption.test.js new file mode 100644 index 0000000..3c41962 --- /dev/null +++ b/test/integration/strategy-self-consumption.test.js @@ -0,0 +1,85 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { FlowGraph } = require('../lib/flow-graph'); +const { Initializer } = require('../support/init-flow'); +const { ContextStore } = require('../lib/context-store'); +const { StateProvider } = require('../lib/ha-state-mock'); +const { twoVenusEPerPhase, oneVenusEPerPhase } = require('../fixtures/homes'); + +describe('Self-consumption strategy integration', () => { + it('charges surplus power when exporting to the grid', async () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const graph = new FlowGraph({ + context: new ContextStore(), + flow: new ContextStore(), + global: initializer.global, + }); + graph.load(['node-red/02 strategy-self-consumption.json', 'node-red/02 strategy-full-stop.json']); + + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ]); + + const msg = { + batteries: twoVenusEPerPhase({ 0: { soc: 50 }, 1: { soc: 50 }, 2: { soc: 50 }, 3: { soc: 50 }, 4: { soc: 50 }, 5: { soc: 50 } }), + grid_power: -4000, + advanced_settings: {}, + phase_protection: { + enabled: false, + command_limits_available: false, + command_limit_by_phase: { + charge: { L1: null, L2: null, L3: null }, + discharge: { L1: null, L2: null, L3: null }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Self-consumption', msg); + + assert.equal(terminals.length, 1); + assert.equal(terminals[0].solutions.length, 6); + assert.ok(terminals[0].solutions.every((s) => s.mode === 'charge' && s.power > 0)); + }); + + it('discharges to cover import when drawing from the grid', async () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const graph = new FlowGraph({ + context: new ContextStore(), + flow: new ContextStore(), + global: initializer.global, + }); + graph.load(['node-red/02 strategy-self-consumption.json', 'node-red/02 strategy-full-stop.json']); + + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ]); + + const msg = { + batteries: oneVenusEPerPhase([{ soc: 90 }, { soc: 90 }, { soc: 90 }]), + grid_power: 3000, + advanced_settings: {}, + phase_protection: { + enabled: false, + command_limits_available: false, + command_limit_by_phase: { + charge: { L1: null, L2: null, L3: null }, + discharge: { L1: null, L2: null, L3: null }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Self-consumption', msg); + + assert.equal(terminals.length, 1); + assert.equal(terminals[0].solutions.length, 3); + assert.ok(terminals[0].solutions.every((s) => s.mode === 'discharge' && s.power > 0)); + }); +}); diff --git a/test/integration/strategy-sell.test.js b/test/integration/strategy-sell.test.js new file mode 100644 index 0000000..152cbff --- /dev/null +++ b/test/integration/strategy-sell.test.js @@ -0,0 +1,102 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { FlowGraph } = require('../lib/flow-graph'); +const { Initializer } = require('../support/init-flow'); +const { ContextStore } = require('../lib/context-store'); +const { StateProvider } = require('../lib/ha-state-mock'); +const { singleVenusE, twoVenusEPerPhase } = require('../fixtures/homes'); + +describe('Sell strategy integration', () => { + it('discharges at max power without limits', async () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const graph = new FlowGraph({ + context: new ContextStore(), + flow: new ContextStore(), + global: initializer.global, + }); + graph.load(['node-red/02 strategy-sell.json', 'node-red/02 strategy-full-stop.json']); + + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ['input_select.house_battery_strategy_sell_goal', 'state of charge'], + ['input_number.house_battery_strategy_sell_target_soc', '11'], + ['input_select.house_battery_strategy_sell_goal_reached', 'Full stop'], + ]); + + const msg = { + batteries: singleVenusE({ soc: 90 }), + grid_power_has_limit_export: false, + phase_protection: { + enabled: false, + command_limits_available: false, + command_limit_by_phase: { + charge: { L1: null, L2: null, L3: null }, + discharge: { L1: null, L2: null, L3: null }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Sell', msg); + + assert.equal(terminals.length, 1); + assert.deepEqual(terminals[0].solutions, [ + { id: 'M1', mode: 'discharge', power: 2500 }, + ]); + }); + + it('throttles max-power discharge when phase command limits are lower', async () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const graph = new FlowGraph({ + context: new ContextStore(), + flow: new ContextStore(), + global: initializer.global, + }); + graph.load(['node-red/02 strategy-sell.json', 'node-red/02 strategy-full-stop.json']); + + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ['input_select.house_battery_strategy_sell_goal', 'state of charge'], + ['input_number.house_battery_strategy_sell_target_soc', '11'], + ['input_select.house_battery_strategy_sell_goal_reached', 'Full stop'], + ]); + + const msg = { + batteries: twoVenusEPerPhase({ + 0: { soc: 90 }, + 1: { soc: 90 }, + 2: { soc: 90 }, + 3: { soc: 90 }, + 4: { soc: 90 }, + 5: { soc: 90 }, + }), + grid_power_has_limit_export: false, + phase_protection: { + enabled: false, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: null, L2: null, L3: null }, + discharge: { L1: 3000, L2: 3000, L3: 3000 }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Sell', msg); + + assert.equal(terminals.length, 1); + const solutions = terminals[0].solutions; + assert.equal(solutions[0].power, 2500); + assert.equal(solutions[1].power, 500); + assert.equal(solutions[2].power, 2500); + assert.equal(solutions[3].power, 500); + assert.equal(solutions[4].power, 2500); + assert.equal(solutions[5].power, 500); + }); +}); diff --git a/test/lib/context-store.js b/test/lib/context-store.js new file mode 100644 index 0000000..5b17ce4 --- /dev/null +++ b/test/lib/context-store.js @@ -0,0 +1,35 @@ +'use strict'; + +class ContextStore { + /** + * @param {Record} [initial] + */ + constructor(initial = {}) { + this.store = new Map(Object.entries(initial)); + } + + /** + * @param {string} key + */ + get(key) { + return this.store.get(key); + } + + /** + * @param {string} key + * @param {unknown} value + */ + set(key, value) { + this.store.set(key, value); + return value; + } + + /** + * @returns {string[]} + */ + keys() { + return Array.from(this.store.keys()); + } +} + +module.exports = { ContextStore }; diff --git a/test/lib/flow-graph.js b/test/lib/flow-graph.js new file mode 100644 index 0000000..9993324 --- /dev/null +++ b/test/lib/flow-graph.js @@ -0,0 +1,352 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const RED = require('./red-util'); +const { FunctionRunner } = require('./function-runner'); +const { resolveTemplate, StateProvider, TemplateProvider } = require('./ha-state-mock'); + +function convertRuleValue(value, type, msg, stores) { + switch (type) { + case 'str': return String(value); + case 'num': return Number(value); + case 'bool': return value === true || value === 'true'; + case 're': return new RegExp(value); + case 'flow': return stores.flow.get(value); + case 'global': return stores.global.get(value); + case 'msg': return RED.getMessageProperty(msg, value); + default: return value; + } +} + +class FlowGraph { + /** + * @param {object} options + * @param {ContextStore} options.context + * @param {ContextStore} options.flow + * @param {ContextStore} options.global + * @param {StateProvider} [options.stateProvider] + * @param {TemplateProvider} [options.templateProvider] + * @param {object} [options.clock] + */ + constructor(options = {}) { + this.nodes = new Map(); + this.configNodes = new Map(); + this.idsByName = new Map(); + this.context = options.context; + this.flow = options.flow; + this.global = options.global; + this.stateProvider = options.stateProvider || new StateProvider(); + this.templateProvider = options.templateProvider || new TemplateProvider(); + this.clock = options.clock || { now: () => Date.now() }; + this.runner = new FunctionRunner({ captureStatus: false, captureWarnings: true, captureErrors: true, captureLogs: false }); + this.maxDepth = 200; + this.onVisit = options.onVisit; + } + + load(flowFiles) { + for (const file of flowFiles) { + const fullPath = path.resolve(__dirname, '../../', file); + const flow = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + for (const node of flow) { + if (!node || !node.id) continue; + if (node.type === 'server' || node.type === 'global-config') { + this.configNodes.set(node.id, node); + continue; + } + if (!this.nodes.has(node.id)) { + this.nodes.set(node.id, node); + if (node.name) { + const key = `${node.type}:${node.name}`; + if (!this.idsByName.has(key)) { + this.idsByName.set(key, node.id); + } + } + } + } + } + } + + getNode(id) { return this.nodes.get(id); } + getNodeIdByName(type, name) { return this.idsByName.get(`${type}:${name}`); } + getNodeByName(type, name) { return this.nodes.get(this.getNodeIdByName(type, name)); } + + /** + * Start at a strategy `link in` by name and run until terminal `link out` return. + * @param {string} strategyName + * @param {object} startMsg + * @returns {Promise} terminal messages from link out return nodes + */ + async run(strategyName, startMsg) { + const startNode = this.getNodeByName('link in', strategyName); + if (!startNode) { + throw new Error(`link in "${strategyName}" not found`); + } + const terminals = []; + await this._visit(startNode.id, startMsg, 0, terminals); + return terminals; + } + + async _visit(nodeId, msg, depth, terminals) { + if (depth > this.maxDepth) { + throw new Error(`Max traversal depth exceeded at node ${nodeId}`); + } + const node = this.nodes.get(nodeId); + if (!node) return; + if (this.onVisit) this.onVisit(node, msg, depth); + + const result = await this._execNode(node, msg, depth); + + if (node.type === 'link out') { + if (node.mode === 'return') { + terminals.push(msg); + return; + } + // Static link to another node (usually link in "link to End") + for (const targetId of node.links || []) { + await this._visit(targetId, msg, depth + 1, terminals); + } + return; + } + + for (let i = 0; i < result.length; i++) { + const outMsg = result[i]; + if (outMsg === null || outMsg === undefined) continue; + const wires = node.wires?.[i] || []; + for (const targetId of wires) { + await this._visit(targetId, outMsg, depth + 1, terminals); + } + } + } + + async _execNode(node, msg, depth) { + const stores = { context: this.context, flow: this.flow, global: this.global }; + + switch (node.type) { + case 'function': { + const result = this.runner.run({ node, msg, context: this.context, flow: this.flow, global: this.global }); + return this._functionOutputs(node, result); + } + case 'change': return this._applyChangeRules(node, msg); + case 'switch': return this._evalSwitch(node, msg); + case 'link in': return [msg]; + case 'link out': return []; + case 'link call': return this._evalLinkCall(node, msg, depth); + case 'api-current-state': return this._evalApiCurrentState(node, msg); + case 'api-call-service': return [msg]; + case 'api-render-template': return this._evalApiRenderTemplate(node, msg); + case 'time-range-switch': return this._evalTimeRangeSwitch(node, msg); + case 'rbe': + case 'smooth': + case 'delay': + case 'trigger': + case 'inject': + case 'junction': return [msg]; + case 'debug': return []; + default: return [msg]; + } + } + + _functionOutputs(node, result) { + if (result === null || result === undefined) return []; + if (Array.isArray(result) && (node.outputs || 1) > 1) { + const arr = result.slice(0, node.outputs); + while (arr.length < node.outputs) arr.push(undefined); + return arr; + } + return [result]; + } + + _applyChangeRules(node, msg) { + for (const rule of node.rules || []) { + const p = rule.p; + const pt = rule.pt || 'msg'; + switch (rule.t) { + case 'set': { + let value; + if (rule.tot === 'entityState') { + value = this.stateProvider.getState(resolveTemplate(node.entity_id, msg)); + } else { + value = this._convertValue(rule.to, rule.tot, msg); + } + this._setProperty(pt, p, value, msg); + break; + } + case 'delete': { + this._deleteProperty(pt, p, msg); + break; + } + case 'move': { + const src = this._getProperty(rule.p, msg); + this._deleteProperty(pt, rule.p, msg); + this._setProperty(rule.to_pt || 'msg', rule.to, src, msg); + break; + } + case 'change': { + const current = this._getProperty(pt, p, msg); + if (typeof current === 'string') { + const from = new RegExp(rule.from, 'g'); + this._setProperty(pt, p, current.replace(from, rule.to), msg); + } + break; + } + } + } + return [msg]; + } + + _getProperty(pt, p, msg) { + if (pt === 'msg') return RED.getMessageProperty(msg, p); + if (pt === 'flow') return this.flow.get(p); + if (pt === 'global') return this.global.get(p); + if (pt === 'payload') return msg.payload; + return undefined; + } + + _setProperty(pt, p, value, msg) { + if (pt === 'msg') RED.setMessageProperty(msg, p, value, true); + else if (pt === 'flow') this.flow.set(p, value); + else if (pt === 'global') this.global.set(p, value); + else if (pt === 'payload') msg.payload = value; + } + + _deleteProperty(pt, p, msg) { + if (pt === 'msg') { + const parts = p.split('.'); + let target = msg; + for (let i = 0; i < parts.length - 1; i++) { + target = target?.[parts[i]]; + } + if (target) delete target[parts[parts.length - 1]]; + } + } + + _convertValue(value, type, msg) { + switch (type) { + case 'str': return String(value); + case 'num': return Number(value); + case 'bool': return value === true || value === 'true'; + case 'json': return JSON.parse(value); + case 'date': return this.clock.now(); + case 'msg': return RED.getMessageProperty(msg, value); + case 'flow': return this.flow.get(value); + case 'global': return this.global.get(value); + case 'env': return process.env[value]; + default: return value; + } + } + + _evalSwitch(node, msg) { + const property = RED.getMessageProperty(msg, node.property); + for (let i = 0; i < (node.rules || []).length; i++) { + const rule = node.rules[i]; + const check = this._evalSwitchRule(rule, property, msg); + if (check) { + const arr = new Array(node.outputs || 1).fill(undefined); + arr[i] = msg; + return arr; + } + } + return new Array(node.outputs || 1).fill(undefined); + } + + _evalSwitchRule(rule, property, msg) { + const v = convertRuleValue(rule.v, rule.vt, msg, { flow: this.flow, global: this.global }); + switch (rule.t) { + case 'eq': return String(property) === String(v); + case 'neq': return String(property) !== String(v); + case 'gt': return Number(property) > Number(v); + case 'gte': return Number(property) >= Number(v); + case 'lt': return Number(property) < Number(v); + case 'lte': return Number(property) <= Number(v); + case 'cont': return String(property).includes(String(v)); + case 'true': return property === true; + case 'false': return property === false; + case 'null': return property == null; + case 'nnull': return property != null; + case 'else': return true; + default: return false; + } + } + + async _evalLinkCall(node, msg, depth) { + let targetIds = []; + if (node.linkType === 'dynamic') { + const targetName = RED.getMessageProperty(msg, 'target'); + const targetId = this.getNodeIdByName('link in', targetName); + if (!targetId) { + throw new Error(`Dynamic link call could not resolve link in "${targetName}"`); + } + targetIds = [targetId]; + } else { + targetIds = node.links || []; + } + + let outMsg = msg; + for (const targetId of targetIds) { + const terminals = []; + await this._visit(targetId, msg, depth + 1, terminals); + if (terminals.length > 0) { + outMsg = terminals[0]; + } + } + return [outMsg]; + } + + _evalApiCurrentState(node, msg) { + const entityId = resolveTemplate(node.entity_id, msg); + const state = this.stateProvider.getState(entityId); + for (const prop of node.outputProperties || []) { + let value; + if (prop.valueType === 'entityState') { + value = state; + } else { + value = this._convertValue(prop.value, prop.valueType, msg); + } + this._setProperty(prop.propertyType || 'msg', prop.property, value, msg); + } + if (node.state_location) { + this._setProperty('msg', node.state_location, state, msg); + } + return [msg]; + } + + _evalApiRenderTemplate(node, msg) { + const rendered = this.templateProvider.render(node.name, node.entity_id, msg); + if (rendered !== undefined) { + this._setProperty(node.resultsLocationType || 'msg', node.resultsLocation, rendered, msg); + } + return [msg]; + } + + _evalTimeRangeSwitch(node, msg) { + // The timed strategy uses msg.__config set by a preceding function node. + const config = msg.__config || {}; + const start = config.startTime; + const end = config.endTime; + if (!start || !end) { + return [undefined, msg]; + } + const now = this.clock.now ? new Date(this.clock.now()) : new Date(); + if (this._isInPeriod(now, start, end)) { + return [msg, undefined]; + } + return [undefined, msg]; + } + + _isInPeriod(now, startStr, endStr) { + const toMinutes = (timeStr) => { + const [h, m = 0] = String(timeStr).split(':'); + return Number(h) * 60 + Number(m); + }; + const current = now.getHours() * 60 + now.getMinutes(); + const start = toMinutes(startStr); + const end = toMinutes(endStr); + if (start <= end) { + return current >= start && current < end; + } + return current >= start || current < end; + } +} + +module.exports = { FlowGraph }; diff --git a/test/lib/function-runner.js b/test/lib/function-runner.js new file mode 100644 index 0000000..158bf1c --- /dev/null +++ b/test/lib/function-runner.js @@ -0,0 +1,119 @@ +'use strict'; + +const fs = require('fs'); +const path = require('path'); +const RED = require('./red-util'); +const { ContextStore } = require('./context-store'); + +class FunctionRunner { + /** + * @param {object} [options] + * @param {boolean} [options.captureStatus=true] + * @param {boolean} [options.captureWarnings=true] + * @param {boolean} [options.captureErrors=true] + * @param {boolean} [options.captureLogs=true] + */ + constructor(options = {}) { + this.options = { + captureStatus: options.captureStatus !== false, + captureWarnings: options.captureWarnings !== false, + captureErrors: options.captureErrors !== false, + captureLogs: options.captureLogs !== false, + }; + this.reset(); + } + + reset() { + this.status = []; + this.warnings = []; + this.errors = []; + this.logs = []; + } + + /** + * Load a function node body from a flow export. + * @param {string} flowFile - relative path from project root, e.g. "node-red/02 strategy-full-stop.json" + * @param {string|{name?: string, id?: string}} nodeRef - function node name or {id}/{name} + * @returns {string} + */ + loadFunctionCode(flowFile, nodeRef) { + const fullPath = path.resolve(__dirname, '../../', flowFile); + const flow = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + + const node = flow.find((n) => { + if (!n || n.type !== 'function') return false; + if (typeof nodeRef === 'string') return n.name === nodeRef || n.id === nodeRef; + if (nodeRef.id) return n.id === nodeRef.id; + if (nodeRef.name) return n.name === nodeRef.name; + return false; + }); + + if (!node) { + throw new Error( + `Function node ${JSON.stringify(nodeRef)} not found in ${flowFile}` + ); + } + + return node.func; + } + + /** + * Execute a function node. + * @param {object} params + * @param {string} params.flowFile + * @param {string|object} params.node - function node name or {name, id} + * @param {object} [params.msg={}] + * @param {ContextStore} [params.context] + * @param {ContextStore} [params.flow] + * @param {ContextStore} [params.global] + * @returns {unknown} - the return value from the function node (may be array) + */ + run({ flowFile, node: nodeRef, msg = {}, context, flow, global }) { + this.reset(); + const code = flowFile + ? this.loadFunctionCode(flowFile, nodeRef) + : this.loadFunctionCodeByNode(nodeRef); + return this.execute(code, nodeRef, msg, context, flow, global); + } + + loadFunctionCodeByNode(node) { + if (!node || typeof node.func !== 'string') { + throw new Error('Function node object with func string required'); + } + return node.func; + } + + execute(code, nodeRef, msg = {}, context, flow, global) { + const contextStore = context || new ContextStore(); + const flowStore = flow || new ContextStore(); + const globalStore = global || new ContextStore(); + + const fn = new Function('msg', 'node', 'context', 'flow', 'global', 'RED', code); + + const nodeName = typeof nodeRef === 'string' + ? nodeRef + : (nodeRef?.name || nodeRef?.id || 'unknown'); + const nodeId = typeof nodeRef === 'object' && nodeRef?.id ? nodeRef.id : nodeName; + + const nodeMock = { + status: this.options.captureStatus ? (obj) => { this.status.push(obj); } : () => {}, + warn: this.options.captureWarnings ? (text) => { this.warnings.push(text); } : () => {}, + error: this.options.captureErrors ? (text, message) => { this.errors.push({ text, msg: message }); } : () => {}, + log: this.options.captureLogs ? (text) => { this.logs.push(text); } : () => {}, + }; + + const thisObj = { + __node__: { id: nodeId, name: nodeName }, + env: { + get: (key) => flowStore.get(key), + }, + msg, + }; + + const REDMock = { util: RED }; + + return fn.call(thisObj, msg, nodeMock, contextStore, flowStore, globalStore, REDMock); + } +} + +module.exports = { FunctionRunner }; diff --git a/test/lib/ha-state-mock.js b/test/lib/ha-state-mock.js new file mode 100644 index 0000000..437ef4e --- /dev/null +++ b/test/lib/ha-state-mock.js @@ -0,0 +1,51 @@ +'use strict'; + +// Simple Mustache-like resolver for {{property}} tokens. +function resolveTemplate(template, msg) { + if (typeof template !== 'string') return template; + return template.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, path) => { + const value = path.split('.').reduce((obj, key) => (obj != null ? obj[key] : undefined), msg); + return value === undefined ? '' : String(value); + }); +} + +class StateProvider { + /** + * @param {Map|Function} [resolver] + */ + constructor(resolver) { + if (typeof resolver === 'function') { + this.resolve = resolver; + } else if (resolver instanceof Map) { + this.resolve = (id) => resolver.get(id) ?? ''; + } else { + this.resolve = () => ''; + } + } + + getState(entityId) { + return this.resolve(entityId) ?? ''; + } +} + +class TemplateProvider { + /** + * @param {Record} templates + */ + constructor(templates = {}) { + this.templates = templates; + } + + render(name, entityId, msg) { + if (this.templates[name]) { + return this.templates[name](entityId, msg, resolveTemplate); + } + return ''; + } +} + +module.exports = { + resolveTemplate, + StateProvider, + TemplateProvider, +}; diff --git a/test/lib/red-util.js b/test/lib/red-util.js new file mode 100644 index 0000000..3e826f8 --- /dev/null +++ b/test/lib/red-util.js @@ -0,0 +1,3 @@ +'use strict'; + +module.exports = require('@node-red/util').util; diff --git a/test/support/init-flow.js b/test/support/init-flow.js new file mode 100644 index 0000000..86e97a0 --- /dev/null +++ b/test/support/init-flow.js @@ -0,0 +1,41 @@ +'use strict'; + +const { FunctionRunner } = require('../lib/function-runner'); +const { ContextStore } = require('../lib/context-store'); + +class Initializer { + /** + * @param {object} options + * @param {ContextStore} [options.global] + * @param {boolean} [options.debugMode=false] + */ + constructor({ global: globalStore, debugMode = false } = {}) { + this.global = globalStore || new ContextStore(); + this.debugMode = debugMode; + } + + /** + * Run the branch-101 "Custom logger" function, which also initializes + * `phasePowerAllocator`, `logger`, and `unhandledException` as globals. + */ + initialize() { + const runner = new FunctionRunner({ captureStatus: false, captureWarnings: false, captureErrors: false, captureLogs: false }); + runner.run({ + flowFile: 'node-red/01 start-flow.json', + node: 'Custom logger', + msg: {}, + global: this.global, + }); + this.global.set('debug_mode', this.debugMode); + } + + /** + * Provide a no-op logger global for tests that don't need the real one. + */ + useNoopLogger() { + this.global.set('logger', () => {}); + this.global.set('unhandledException', () => {}); + } +} + +module.exports = { Initializer }; diff --git a/test/unit/phase/command-limits.test.js b/test/unit/phase/command-limits.test.js new file mode 100644 index 0000000..0bbb07d --- /dev/null +++ b/test/unit/phase/command-limits.test.js @@ -0,0 +1,94 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { FunctionRunner } = require('../../lib/function-runner'); +const { Initializer } = require('../../support/init-flow'); +const { oneVenusEPerPhase } = require('../../fixtures/homes'); + +describe('Phase command limits', () => { + it('computes correct charge/discharge limits when readings are within bounds', () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const runner = new FunctionRunner(); + const msg = { + batteries: oneVenusEPerPhase(), + grid_power_phase: { L1: 500, L2: -300, L3: 0 }, + grid_power_limit_phase: 5500, + phase_protection: { enabled: true }, + }; + + const result = runner.run({ + flowFile: 'node-red/01 start-flow.json', + node: 'Phase command limits', + msg, + global: initializer.global, + }); + + assert.equal( + result.phase_protection.command_limits_available, + true, + 'limits should be available' + ); + assert.deepEqual(result.phase_protection.command_limit_by_phase.charge, { + L1: 5000, + L2: 5800, + L3: 5500, + }); + assert.deepEqual(result.phase_protection.command_limit_by_phase.discharge, { + L1: 6000, + L2: 5200, + L3: 5500, + }); + }); + + it('marks limits unavailable when a phase sensor is missing', () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const runner = new FunctionRunner(); + const msg = { + batteries: oneVenusEPerPhase(), + grid_power_phase: { L1: 500, L2: null, L3: 0 }, + grid_power_limit_phase: 5500, + phase_protection: { enabled: true }, + }; + + const result = runner.run({ + flowFile: 'node-red/01 start-flow.json', + node: 'Phase command limits', + msg, + global: initializer.global, + }); + + assert.equal(result.phase_protection.command_limits_available, false); + assert.deepEqual(result.phase_protection.command_limit_by_phase.charge, { + L1: null, + L2: null, + L3: null, + }); + }); + + it('marks limits unavailable when phase protection is disabled', () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const runner = new FunctionRunner(); + const msg = { + batteries: oneVenusEPerPhase(), + grid_power_phase: { L1: 500, L2: -300, L3: 0 }, + grid_power_limit_phase: 5500, + phase_protection: { enabled: false }, + }; + + const result = runner.run({ + flowFile: 'node-red/01 start-flow.json', + node: 'Phase command limits', + msg, + global: initializer.global, + }); + + assert.equal(result.phase_protection.command_limits_available, false); + }); +}); diff --git a/test/unit/phase/protection-guard.test.js b/test/unit/phase/protection-guard.test.js new file mode 100644 index 0000000..4f0e504 --- /dev/null +++ b/test/unit/phase/protection-guard.test.js @@ -0,0 +1,110 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { FunctionRunner } = require('../../lib/function-runner'); +const { Initializer } = require('../../support/init-flow'); + +describe('Phase protection guard', () => { + it('preempts Charge to Standby/peak shave on an import overload', () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + const runner = new FunctionRunner(); + + const msg = { + target: 'Charge', + batteries: [ + { id: 'M1', phase: 'L1', power: 0 }, + { id: 'M2', phase: 'L2', power: 0 }, + ], + grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, + grid_power_limit_phase: 5500, + phase_protection: { enabled: true }, + }; + + const result = runner.run({ + flowFile: 'node-red/01 start-flow.json', + node: 'Phase protection guard', + msg, + global: initializer.global, + }); + + assert.equal(result.target, 'Standby / peak shave'); + assert.equal(result.phase_protection.preempted_strategy, 'Charge'); + }); + + it('does not preempt Full stop', () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + const runner = new FunctionRunner(); + + const msg = { + target: 'Full stop', + batteries: [ + { id: 'M1', phase: 'L1', power: 0 }, + ], + grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, + grid_power_limit_phase: 5500, + phase_protection: { enabled: true }, + }; + + const result = runner.run({ + flowFile: 'node-red/01 start-flow.json', + node: 'Phase protection guard', + msg, + global: initializer.global, + }); + + assert.equal(result.target, 'Full stop'); + }); + + it('does nothing when phase sensors are missing', () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + const runner = new FunctionRunner(); + + const msg = { + target: 'Charge', + batteries: [ + { id: 'M1', phase: 'L1', power: 0 }, + ], + grid_power_phase: { L1: 6000, L2: null, L3: 0 }, + grid_power_limit_phase: 5500, + phase_protection: { enabled: true }, + }; + + const result = runner.run({ + flowFile: 'node-red/01 start-flow.json', + node: 'Phase protection guard', + msg, + global: initializer.global, + }); + + assert.equal(result.target, 'Charge'); + }); + + it('does nothing when no battery has a phase assigned', () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + const runner = new FunctionRunner(); + + const msg = { + target: 'Charge', + batteries: [ + { id: 'M1', phase: 'unassigned', power: 0 }, + ], + grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, + grid_power_limit_phase: 5500, + phase_protection: { enabled: true }, + }; + + const result = runner.run({ + flowFile: 'node-red/01 start-flow.json', + node: 'Phase protection guard', + msg, + global: initializer.global, + }); + + assert.equal(result.target, 'Charge'); + }); +}); diff --git a/test/unit/strategies/full-stop.test.js b/test/unit/strategies/full-stop.test.js new file mode 100644 index 0000000..d330fe4 --- /dev/null +++ b/test/unit/strategies/full-stop.test.js @@ -0,0 +1,33 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { FunctionRunner } = require('../../lib/function-runner'); + +const { Initializer } = require('../../support/init-flow'); + +describe('Full stop strategy', () => { + it('stops every battery at 0 W', () => { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const runner = new FunctionRunner(); + const msg = { + batteries: [ + { id: 'M1', power: 1200 }, + { id: 'M2', power: -800 }, + ], + }; + const result = runner.run({ + flowFile: 'node-red/02 strategy-full-stop.json', + node: 'Stop all batteries', + msg, + global: initializer.global, + }); + + assert.deepEqual(result.solutions, [ + { id: 'M1', mode: 'stop', power: 0 }, + { id: 'M2', mode: 'stop', power: 0 }, + ]); + }); +}); From 5b85367a4f345b40fa883ab74d629b540965a0ef Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Sat, 20 Jun 2026 15:20:30 +0200 Subject: [PATCH 13/14] improve .gitignore + switch power reference --- .gitignore | 9 +++- docs/06-advanced-features.md | 2 +- .../packages/house_battery_control.yaml | 44 +++++++++---------- 3 files changed, 31 insertions(+), 24 deletions(-) diff --git a/.gitignore b/.gitignore index 57472ed..adf601a 100644 --- a/.gitignore +++ b/.gitignore @@ -6,9 +6,16 @@ notes/ template_sensor_bob_battery_dashboard.yaml .DS_Store -# Ignore SASS cache files +# Ignore opencode local workspace +.opencode/ + docs/.sass-cache +# Node.js dependencies and test output +node_modules/ + +coverage/ + # Claude Code per-user state — keep local; commands/skills/agents/settings.json are shared .claude/settings.local.json .claude/sessions/ diff --git a/docs/06-advanced-features.md b/docs/06-advanced-features.md index 860d925..9f474c1 100644 --- a/docs/06-advanced-features.md +++ b/docs/06-advanced-features.md @@ -65,7 +65,7 @@ nav_order: 6 - **More than 6 batteries:** Override or change `input_number.house_battery_count` and you are good to go. - The dashboard supports 6 batteries out of the box. For 7 or more, duplicate and edit these cards or create your own dashboard. - **Manual phase assignment:** Assign each configured battery to `L1`, `L2`, `L3`, or `Unassigned` from the dashboard. - - The overview shows the assigned phase on each battery header and shows live battery power per phase in kW. + - The overview shows the assigned phase on each battery header and shows live battery AC power per phase in kW. - The Node-RED battery object exposes this as `battery.phase`, so custom strategies can use the mapping. - Optional per-phase grid power aliases can be configured in `packages/house_battery_control_config.yaml` as `sensor.p1_meter_l1_power`, `sensor.p1_meter_l2_power`, and `sensor.p1_meter_l3_power`. Leave them commented out if your setup is not three-phase. - Node-RED exposes configured phase meter values as `msg.grid_power_phase.L1`, `.L2`, and `.L3`, with missing or unreadable values set to `null`. diff --git a/home assistant/packages/house_battery_control.yaml b/home assistant/packages/house_battery_control.yaml index dd669f5..4661961 100644 --- a/home assistant/packages/house_battery_control.yaml +++ b/home assistant/packages/house_battery_control.yaml @@ -914,12 +914,12 @@ template: {# Marstek uses negative power when delivering power, positive when charging. This is inverse from what HA expects.#} {{ (total_power * -1) | round(2) }} - # Battery-side phase interaction totals. + # AC-side phase interaction totals. # Optional grid-side phase meter aliases are configured in # house_battery_control_config.yaml as sensor.p1_meter_l1_power, # sensor.p1_meter_l2_power and sensor.p1_meter_l3_power. - # Live battery power on phase L1 + # Live AC power on phase L1 - name: "House Battery L1 Power" unique_id: "house_battery_l1_power_kw" state_class: measurement @@ -929,26 +929,26 @@ template: state: > {% set total_w = namespace(value=0) %} {% if states('input_select.marstek_m1_phase') == 'L1' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m1_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m1_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m2_phase') == 'L1' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m2_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m2_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m3_phase') == 'L1' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m3_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m3_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m4_phase') == 'L1' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m4_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m4_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m5_phase') == 'L1' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m5_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m5_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m6_phase') == 'L1' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m6_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_ac_power') | float(0)) %} {% endif %} {{ ((total_w.value * -1) / 1000) | round(2) }} - # Live battery power on phase L2 + # Live AC power on phase L2 - name: "House Battery L2 Power" unique_id: "house_battery_l2_power_kw" state_class: measurement @@ -958,26 +958,26 @@ template: state: > {% set total_w = namespace(value=0) %} {% if states('input_select.marstek_m1_phase') == 'L2' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m1_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m1_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m2_phase') == 'L2' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m2_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m2_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m3_phase') == 'L2' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m3_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m3_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m4_phase') == 'L2' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m4_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m4_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m5_phase') == 'L2' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m5_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m5_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m6_phase') == 'L2' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m6_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_ac_power') | float(0)) %} {% endif %} {{ ((total_w.value * -1) / 1000) | round(2) }} - # Live battery power on phase L3 + # Live AC power on phase L3 - name: "House Battery L3 Power" unique_id: "house_battery_l3_power_kw" state_class: measurement @@ -987,22 +987,22 @@ template: state: > {% set total_w = namespace(value=0) %} {% if states('input_select.marstek_m1_phase') == 'L3' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m1_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m1_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m2_phase') == 'L3' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m2_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m2_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m3_phase') == 'L3' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m3_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m3_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m4_phase') == 'L3' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m4_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m4_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m5_phase') == 'L3' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m5_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m5_ac_power') | float(0)) %} {% endif %} {% if states('input_select.marstek_m6_phase') == 'L3' %} - {% set total_w.value = total_w.value + (states('sensor.marstek_m6_battery_power') | float(0)) %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_ac_power') | float(0)) %} {% endif %} {{ ((total_w.value * -1) / 1000) | round(2) }} From 852f1a11c2ba102a6385c4875aca9fffe83e978c Mon Sep 17 00:00:00 2001 From: nickles-lee Date: Sat, 20 Jun 2026 19:59:20 +0200 Subject: [PATCH 14/14] test: harden phase-protection tests across personas; fix harness init & command_limits_available handling --- node-red/02 strategy-charge.json | 2 +- node-red/02 strategy-sell.json | 2 +- package.json | 6 +- test/integration/strategy-charge.test.js | 155 ++++++++++---- test/integration/strategy-dynamic-2.test.js | 161 ++++++++++++++ test/integration/strategy-partials.test.js | 114 ++++++++-- .../strategy-self-consumption.test.js | 97 +++++++-- test/integration/strategy-sell.test.js | 12 +- test/lib/flow-graph.js | 2 +- test/support/init-flow.js | 5 + test/unit/phase/allocator.test.js | 199 ++++++++++++++++++ test/unit/phase/command-limits.test.js | 121 +++++++---- test/unit/phase/protection-guard.test.js | 190 +++++++++++------ 13 files changed, 872 insertions(+), 194 deletions(-) create mode 100644 test/integration/strategy-dynamic-2.test.js create mode 100644 test/unit/phase/allocator.test.js diff --git a/node-red/02 strategy-charge.json b/node-red/02 strategy-charge.json index 8c9cc3d..a31524b 100644 --- a/node-red/02 strategy-charge.json +++ b/node-red/02 strategy-charge.json @@ -294,7 +294,7 @@ "z": "e31b0cf1ca8100ca", "g": "64710d0b7446b478", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseChargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseChargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.charging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"charging_max\", phaseLimits: phaseChargeLimits, allocationMode: \"priority-first\" })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Charge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst limitsAvailable = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst phaseChargeLimits = limitsAvailable\n ? (RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.charge\") || {})\n : {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseChargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.charging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"charging_max\", phaseLimits: phaseChargeLimits, allocationMode: \"priority-first\" })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} charge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"charge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/node-red/02 strategy-sell.json b/node-red/02 strategy-sell.json index dc88009..30c5834 100644 --- a/node-red/02 strategy-sell.json +++ b/node-red/02 strategy-sell.json @@ -251,7 +251,7 @@ "z": "68716753bacc0887", "g": "827abf066017f642", "name": "Max power solution", - "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst phaseDischargeLimits = RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseDischargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.discharging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"discharging_max\", phaseLimits: phaseDischargeLimits })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} discharge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", + "func": "// Logger, usage: log(this, \"\");\nconst log = global.get('logger');\nlog(this, \"Discharge at **max. power**\");\n\n// INPUT\nconst batteries = Array.isArray(msg.batteries) ? msg.batteries : [];\nconst limitsAvailable = RED.util.getMessageProperty(msg, \"phase_protection.command_limits_available\") === true;\nconst phaseDischargeLimits = limitsAvailable\n ? (RED.util.getMessageProperty(msg, \"phase_protection.command_limit_by_phase.discharge\") || {})\n : {};\nconst phaseAllocator = global.get(\"phasePowerAllocator\");\n\nfunction buildFirstFitStates() {\n const PHASES = [\"L1\", \"L2\", \"L3\"];\n const phaseRemaining = { ...phaseDischargeLimits };\n const getPhase = (battery) => {\n const phase = String(battery.phase || \"\").toUpperCase();\n return PHASES.includes(phase) ? phase : \"unassigned\";\n };\n\n return batteries.map((battery) => {\n const phase = getPhase(battery);\n const requestedPower = Math.max(0, Number(battery.discharging_max) || 0);\n const remaining = Number(phaseRemaining[phase]);\n const assignedPower = PHASES.includes(phase) && Number.isFinite(remaining)\n ? Math.min(requestedPower, Math.max(0, remaining))\n : requestedPower;\n\n if (PHASES.includes(phase) && Number.isFinite(remaining)) {\n phaseRemaining[phase] = Math.max(0, remaining - assignedPower);\n }\n\n return { battery, phase, requestedPower, assignedPower };\n });\n}\n\nconst batteryStates = typeof phaseAllocator?.buildMaxPowerStates === \"function\"\n ? phaseAllocator.buildMaxPowerStates({ batteries, maxPowerProperty: \"discharging_max\", phaseLimits: phaseDischargeLimits })\n : buildFirstFitStates();\n\n// build solution\nconst solution_array = batteryStates.map((state) => {\n if (state.assignedPower < state.requestedPower) {\n log(this, `Battery ${state.battery.id}: phase ${state.phase} discharge limit ${state.requestedPower}W -> ${state.assignedPower}W`);\n }\n\n return {\n id: state.battery.id,\n mode: state.assignedPower > 0 ? \"discharge\" : \"stop\",\n power: Math.round(state.assignedPower)\n };\n});\n\n// return solution\nmsg.solutions = solution_array;\nreturn msg;", "outputs": 1, "timeout": 0, "noerr": 0, diff --git a/package.json b/package.json index cd7b267..885e495 100644 --- a/package.json +++ b/package.json @@ -4,9 +4,9 @@ "private": true, "description": "Local unit and integration tests for Home Battery Control Node-RED strategies", "scripts": { - "test": "node --test test/**/*.test.js", - "test:unit": "node --test test/unit/**/*.test.js", - "test:integration": "node --test test/integration/**/*.test.js" + "test": "node --test 'test/**/*.test.js'", + "test:unit": "node --test 'test/unit/**/*.test.js'", + "test:integration": "node --test 'test/integration/**/*.test.js'" }, "devDependencies": { "@node-red/util": "^4.0.0" diff --git a/test/integration/strategy-charge.test.js b/test/integration/strategy-charge.test.js index 4fd2301..8e54b6a 100644 --- a/test/integration/strategy-charge.test.js +++ b/test/integration/strategy-charge.test.js @@ -6,10 +6,16 @@ const { FlowGraph } = require('../lib/flow-graph'); const { Initializer } = require('../support/init-flow'); const { ContextStore } = require('../lib/context-store'); const { StateProvider } = require('../lib/ha-state-mock'); -const { twoVenusEPerPhase, singleVenusEThrottled } = require('../fixtures/homes'); +const { + singleVenusE, + singleVenusEThrottled, + oneVenusEPerPhase, + twoVenusEPerPhase, + heterogeneousSystem, +} = require('../fixtures/homes'); describe('Charge strategy integration', () => { - it('charges at max power without limits', async () => { + function runCharge(home, overrides = {}) { const initializer = new Initializer(); initializer.useNoopLogger(); @@ -26,7 +32,7 @@ describe('Charge strategy integration', () => { ]); const msg = { - batteries: singleVenusEThrottled(), + batteries: home, grid_power_has_limit_import: false, phase_protection: { enabled: false, @@ -36,58 +42,133 @@ describe('Charge strategy integration', () => { discharge: { L1: null, L2: null, L3: null }, }, }, + ...overrides, }; graph.stateProvider = new StateProvider(state); - const terminals = await graph.run('Charge', msg); + return graph.run('Charge', msg); + } + it('charges a single battery at max power with no phase limits', async () => { + const terminals = await runCharge(singleVenusE()); assert.equal(terminals.length, 1); assert.deepEqual(terminals[0].solutions, [ { id: 'M1', mode: 'charge', power: 2500 }, ]); }); - it('throttles max-power charge when phase command limits are lower', async () => { - const initializer = new Initializer(); - initializer.useNoopLogger(); + it('charges one battery per phase at max power with no phase limits', async () => { + const terminals = await runCharge(oneVenusEPerPhase()); + assert.equal(terminals.length, 1); + assert.equal(terminals[0].solutions.length, 3); + assert.ok(terminals[0].solutions.every((s) => s.mode === 'charge' && s.power === 2500)); + }); - const graph = new FlowGraph({ - context: new ContextStore(), - flow: new ContextStore(), - global: initializer.global, - }); - graph.load(['node-red/02 strategy-charge.json']); + it('fairly shares a tight phase limit between two batteries per phase', async () => { + const phaseProtection = { + enabled: false, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 3000, L2: 3000, L3: 3000 }, + discharge: { L1: null, L2: null, L3: null }, + }, + }; + const terminals = await runCharge(twoVenusEPerPhase(), { phase_protection: phaseProtection }); + assert.equal(terminals.length, 1); + const solutions = terminals[0].solutions; + // Two batteries per phase, each wants 2500 W, phase limit 3000 W. + assert.equal(solutions[0].power, 1500); + assert.equal(solutions[1].power, 1500); + assert.equal(solutions[2].power, 1500); + assert.equal(solutions[3].power, 1500); + assert.equal(solutions[4].power, 1500); + assert.equal(solutions[5].power, 1500); + }); - const state = new Map([ - ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], - ['input_select.house_battery_strategy_charge_goal', 'batteries are full'], - ]); + it('charges the full budget for single battery per phase when limited', async () => { + const phaseProtection = { + enabled: false, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 1800, L2: 2000, L3: 2200 }, + discharge: { L1: null, L2: null, L3: null }, + }, + }; + const terminals = await runCharge(oneVenusEPerPhase(), { phase_protection: phaseProtection }); + assert.equal(terminals.length, 1); + const solutions = terminals[0].solutions; + assert.equal(solutions[0].power, 1800); + assert.equal(solutions[1].power, 2000); + assert.equal(solutions[2].power, 2200); + }); - const msg = { - batteries: twoVenusEPerPhase(), - grid_power_has_limit_import: false, - phase_protection: { - enabled: false, - command_limits_available: true, - command_limit_by_phase: { - charge: { L1: 3000, L2: 3000, L3: 3000 }, - discharge: { L1: null, L2: null, L3: null }, - }, + it('preserves charge priority under phase throttling', async () => { + const batteries = twoVenusEPerPhase(); + // Mark the second battery in each phase priority. + batteries[1].is_priority_battery = true; + batteries[3].is_priority_battery = true; + batteries[5].is_priority_battery = true; + + const phaseProtection = { + enabled: false, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 3000, L2: 3000, L3: 3000 }, + discharge: { L1: null, L2: null, L3: null }, }, }; + const terminals = await runCharge(batteries, { phase_protection: phaseProtection }); + assert.equal(terminals.length, 1); + const solutions = terminals[0].solutions; + // M2, M4, M6 are priority and get 2500 W first; M1, M3, M5 get the 500 W remainder. + assert.equal(solutions[0].power, 500); + assert.equal(solutions[1].power, 2500); + assert.equal(solutions[2].power, 500); + assert.equal(solutions[3].power, 2500); + assert.equal(solutions[4].power, 500); + assert.equal(solutions[5].power, 2500); + }); - graph.stateProvider = new StateProvider(state); - const terminals = await graph.run('Charge', msg); + it('redistributes unused phase charge allowance to other batteries on the same phase', async () => { + // Two batteries per phase, one on each phase requests only 1000 W. + const batteries = twoVenusEPerPhase({ + 0: { chargeMaxW: 1000 }, + 2: { chargeMaxW: 1000 }, + 4: { chargeMaxW: 1000 }, + }); + const phaseProtection = { + enabled: false, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 3000, L2: 3000, L3: 3000 }, + discharge: { L1: null, L2: null, L3: null }, + }, + }; + const terminals = await runCharge(batteries, { phase_protection: phaseProtection }); assert.equal(terminals.length, 1); - // First-fit allocation on each phase: each battery wants 2500W; phase limit 3000 - // first battery gets 2500, second gets remaining 500. const solutions = terminals[0].solutions; - assert.equal(solutions[0].power, 2500); - assert.equal(solutions[1].power, 500); - assert.equal(solutions[2].power, 2500); - assert.equal(solutions[3].power, 500); - assert.equal(solutions[4].power, 2500); - assert.equal(solutions[5].power, 500); + // Phase budget 3000. First battery takes 1000, second gets the remaining 2000. + for (let i = 0; i < 6; i += 2) { + assert.equal(solutions[i].power, 1000); + assert.equal(solutions[i + 1].power, 2000); + } + }); + + it('leaves an unassigned single battery unchanged when phase limits are active', async () => { + const phaseProtection = { + enabled: true, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 500, L2: 500, L3: 500 }, + discharge: { L1: null, L2: null, L3: null }, + }, + }; + // Single battery with no phase assignment is not throttled by per-phase limits. + const terminals = await runCharge(singleVenusE(), { phase_protection: phaseProtection }); + assert.equal(terminals.length, 1); + assert.deepEqual(terminals[0].solutions, [ + { id: 'M1', mode: 'charge', power: 2500 }, + ]); }); }); diff --git a/test/integration/strategy-dynamic-2.test.js b/test/integration/strategy-dynamic-2.test.js new file mode 100644 index 0000000..cd30623 --- /dev/null +++ b/test/integration/strategy-dynamic-2.test.js @@ -0,0 +1,161 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { FlowGraph } = require('../lib/flow-graph'); +const { ContextStore } = require('../lib/context-store'); +const { StateProvider } = require('../lib/ha-state-mock'); +const { Initializer } = require('../support/init-flow'); +const { + singleVenusE, + oneVenusEPerPhase, + twoVenusEPerPhase, +} = require('../fixtures/homes'); + +describe('Dynamic 2 strategy integration', () => { + function buildGraph(subStrategy) { + const initializer = new Initializer(); + initializer.useNoopLogger(); + + const flow = new ContextStore(); + flow.set('dynamic_strategy', subStrategy); + + const graph = new FlowGraph({ + context: new ContextStore(), + flow, + global: initializer.global, + }); + graph.load([ + 'node-red/02 strategy-dynamic-2.json', + 'node-red/02 strategy-charge.json', + 'node-red/02 strategy-sell.json', + 'node-red/02 strategy-self-consumption.json', + 'node-red/02 strategy-full-stop.json', + ]); + + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ['input_select.house_battery_strategy_charge_goal', 'batteries are full'], + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ['input_select.house_battery_strategy_sell_goal', 'state of charge'], + ['input_number.house_battery_strategy_sell_target_soc', '11'], + ['input_select.house_battery_strategy_sell_goal_reached', 'Full stop'], + ]); + graph.stateProvider = new StateProvider(state); + + return { graph, flow, initializer }; + } + + function makeMsg(batteries) { + return { + batteries, + grid_power_has_limit_import: false, + grid_power_has_limit_export: false, + phase_protection: { + enabled: false, + command_limits_available: false, + command_limit_by_phase: { + charge: { L1: null, L2: null, L3: null }, + discharge: { L1: null, L2: null, L3: null }, + }, + }, + }; + } + + it('executes the cached Charge sub-strategy without phase limits', async () => { + const { graph } = buildGraph('Charge'); + const msg = makeMsg(oneVenusEPerPhase()); + + const terminals = await graph.run('Dynamic 2', msg); + + assert.equal(terminals.length, 1); + assert.equal(terminals[0].solutions.length, 3); + assert.ok(terminals[0].solutions.every((s) => s.mode === 'charge' && s.power === 2500)); + }); + + it('executes the cached Sell sub-strategy without phase limits', async () => { + const { graph } = buildGraph('Sell'); + const msg = makeMsg(oneVenusEPerPhase([{ soc: 90 }, { soc: 90 }, { soc: 90 }])); + + const terminals = await graph.run('Dynamic 2', msg); + + assert.equal(terminals.length, 1); + assert.equal(terminals[0].solutions.length, 3); + assert.ok(terminals[0].solutions.every((s) => s.mode === 'discharge' && s.power === 2500)); + }); + + it('throttles Charge via per-phase command limits', async () => { + const { graph } = buildGraph('Charge'); + const msg = makeMsg(twoVenusEPerPhase()); + msg.phase_protection = { + enabled: false, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 3000, L2: 3000, L3: 3000 }, + discharge: { L1: null, L2: null, L3: null }, + }, + }; + + const terminals = await graph.run('Dynamic 2', msg); + + assert.equal(terminals.length, 1); + const solutions = terminals[0].solutions; + assert.equal(solutions.length, 6); + assert.ok(solutions.every((s) => s.mode === 'charge')); + for (let i = 0; i < 6; i += 2) { + assert.equal(solutions[i].power, 1500); + assert.equal(solutions[i + 1].power, 1500); + } + }); + + it('throttles Sell via per-phase command limits', async () => { + const { graph } = buildGraph('Sell'); + const msg = makeMsg(twoVenusEPerPhase({ + 0: { soc: 90 }, + 1: { soc: 90 }, + 2: { soc: 90 }, + 3: { soc: 90 }, + 4: { soc: 90 }, + 5: { soc: 90 }, + })); + msg.phase_protection = { + enabled: false, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: null, L2: null, L3: null }, + discharge: { L1: 3000, L2: 3000, L3: 3000 }, + }, + }; + + const terminals = await graph.run('Dynamic 2', msg); + + assert.equal(terminals.length, 1); + const solutions = terminals[0].solutions; + assert.equal(solutions.length, 6); + assert.ok(solutions.every((s) => s.mode === 'discharge')); + for (let i = 0; i < 6; i += 2) { + assert.equal(solutions[i].power, 1500); + assert.equal(solutions[i + 1].power, 1500); + } + }); + + it('leaves an unassigned single battery untouched by phase limits', async () => { + const { graph } = buildGraph('Charge'); + const msg = makeMsg(singleVenusE()); + msg.phase_protection = { + enabled: true, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 500, L2: 500, L3: 500 }, + discharge: { L1: null, L2: null, L3: null }, + }, + }; + + const terminals = await graph.run('Dynamic 2', msg); + + assert.equal(terminals.length, 1); + assert.deepEqual(terminals[0].solutions, [ + { id: 'M1', mode: 'charge', power: 2500 }, + ]); + }); +}); diff --git a/test/integration/strategy-partials.test.js b/test/integration/strategy-partials.test.js index 7456611..3bffedc 100644 --- a/test/integration/strategy-partials.test.js +++ b/test/integration/strategy-partials.test.js @@ -6,10 +6,14 @@ const { FlowGraph } = require('../lib/flow-graph'); const { Initializer } = require('../support/init-flow'); const { ContextStore } = require('../lib/context-store'); const { StateProvider } = require('../lib/ha-state-mock'); -const { oneVenusEPerPhase } = require('../fixtures/homes'); +const { + singleVenusE, + oneVenusEPerPhase, + twoVenusEPerPhase, +} = require('../fixtures/homes'); describe('Partials strategy integration', () => { - it('peak shaves import by discharging when grid power exceeds import limit', async () => { + function buildGraph() { const initializer = new Initializer(); initializer.useNoopLogger(); @@ -18,13 +22,16 @@ describe('Partials strategy integration', () => { flow: new ContextStore(), global: initializer.global, }); - graph.load([ 'node-red/02 strategy-partials.json', 'node-red/02 strategy-self-consumption.json', 'node-red/02 strategy-full-stop.json', ]); + return { graph, initializer }; + } + it('peak shaves import by discharging when grid power exceeds import limit', async () => { + const { graph } = buildGraph(); const state = new Map([ ['input_boolean.house_battery_grid_power_has_limit_import', 'on'], ['input_number.house_battery_grid_power_limit_import', '2500'], @@ -38,7 +45,6 @@ describe('Partials strategy integration', () => { grid_power_limit_import: 2500, grid_power_has_limit_import: true, grid_power_has_limit_export: false, - advanced_settings: {}, phase_protection: { enabled: false, command_limits_available: false, @@ -58,21 +64,7 @@ describe('Partials strategy integration', () => { }); it('peak shaves export by charging when grid power exceeds export limit', async () => { - const initializer = new Initializer(); - initializer.useNoopLogger(); - - const graph = new FlowGraph({ - context: new ContextStore(), - flow: new ContextStore(), - global: initializer.global, - }); - - graph.load([ - 'node-red/02 strategy-partials.json', - 'node-red/02 strategy-self-consumption.json', - 'node-red/02 strategy-full-stop.json', - ]); - + const { graph } = buildGraph(); const state = new Map([ ['input_boolean.house_battery_grid_power_has_limit_export', 'on'], ['input_number.house_battery_grid_power_limit_export', '2500'], @@ -86,7 +78,6 @@ describe('Partials strategy integration', () => { grid_power_limit_export: 2500, grid_power_has_limit_import: false, grid_power_has_limit_export: true, - advanced_settings: {}, phase_protection: { enabled: false, command_limits_available: false, @@ -104,4 +95,87 @@ describe('Partials strategy integration', () => { assert.equal(terminals[0].solutions.length, 3); assert.ok(terminals[0].solutions.every((s) => s.mode === 'charge' && s.power > 0)); }); + + describe('per-phase peak shaving when aggregate grid power is inside limits', () => { + function makeMsg(batteries) { + return { + target: 'Charge', + batteries, + grid_power: 0, + grid_power_limit_phase: 5500, + grid_power_has_limit_import: false, + grid_power_has_limit_export: false, + phase_protection: { + enabled: true, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 3000, L2: 3000, L3: 3000 }, + discharge: { L1: 3000, L2: 3000, L3: 3000 }, + }, + }, + advanced_settings: {}, + }; + } + + it('triggers import peak shave when a single phase is overloaded', async () => { + const { graph } = buildGraph(); + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ]); + + const msg = makeMsg(oneVenusEPerPhase([{ soc: 90 }, { soc: 90 }, { soc: 90 }])); + msg.grid_power_phase = { L1: 6000, L2: 0, L3: 0 }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Standby / peak shave', msg); + + assert.equal(terminals.length, 1); + assert.equal(terminals[0].target, 'Self-consumption'); + assert.ok(terminals[0].solutions.some((s) => s.mode === 'discharge' && s.power > 0)); + assert.ok(terminals[0].solutions.every((s) => s.power <= 3000)); + }); + + it('triggers export peak shave when a single phase is overloaded', async () => { + const { graph } = buildGraph(); + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ]); + + const msg = makeMsg(oneVenusEPerPhase([{ soc: 50 }, { soc: 50 }, { soc: 50 }])); + msg.grid_power_phase = { L1: -6000, L2: 0, L3: 0 }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Standby / peak shave', msg); + + assert.equal(terminals.length, 1); + assert.equal(terminals[0].target, 'Self-consumption'); + assert.ok(terminals[0].solutions.some((s) => s.mode === 'charge' && s.power > 0)); + assert.ok(terminals[0].solutions.every((s) => s.power <= 3000)); + }); + + it('is not relevant for a single unassigned battery', async () => { + const { graph } = buildGraph(); + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ]); + + const msg = makeMsg(singleVenusE({ soc: 90 })); + msg.grid_power_phase = { L1: 6000, L2: 0, L3: 0 }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Standby / peak shave', msg); + + // The partials logic may return no terminal early when there is no + // assigned battery on the overloaded phase. If it does return, the + // solution must not create a meaningful active discharge. + assert.ok( + terminals.length === 0 || + terminals[0].solutions.every((s) => s.mode === 'stop' || s.power === 0 || s.power === 1), + 'expected no meaningful discharge from an unassigned battery' + ); + }); + }); }); diff --git a/test/integration/strategy-self-consumption.test.js b/test/integration/strategy-self-consumption.test.js index 3c41962..a1a27e1 100644 --- a/test/integration/strategy-self-consumption.test.js +++ b/test/integration/strategy-self-consumption.test.js @@ -6,10 +6,14 @@ const { FlowGraph } = require('../lib/flow-graph'); const { Initializer } = require('../support/init-flow'); const { ContextStore } = require('../lib/context-store'); const { StateProvider } = require('../lib/ha-state-mock'); -const { twoVenusEPerPhase, oneVenusEPerPhase } = require('../fixtures/homes'); +const { + singleVenusE, + oneVenusEPerPhase, + twoVenusEPerPhase, +} = require('../fixtures/homes'); describe('Self-consumption strategy integration', () => { - it('charges surplus power when exporting to the grid', async () => { + function buildGraph() { const initializer = new Initializer(); initializer.useNoopLogger(); @@ -19,7 +23,11 @@ describe('Self-consumption strategy integration', () => { global: initializer.global, }); graph.load(['node-red/02 strategy-self-consumption.json', 'node-red/02 strategy-full-stop.json']); + return { graph, initializer }; + } + it('charges surplus power when exporting to the grid', async () => { + const { graph } = buildGraph(); const state = new Map([ ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], ]); @@ -47,16 +55,7 @@ describe('Self-consumption strategy integration', () => { }); it('discharges to cover import when drawing from the grid', async () => { - const initializer = new Initializer(); - initializer.useNoopLogger(); - - const graph = new FlowGraph({ - context: new ContextStore(), - flow: new ContextStore(), - global: initializer.global, - }); - graph.load(['node-red/02 strategy-self-consumption.json', 'node-red/02 strategy-full-stop.json']); - + const { graph } = buildGraph(); const state = new Map([ ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], ]); @@ -82,4 +81,78 @@ describe('Self-consumption strategy integration', () => { assert.equal(terminals[0].solutions.length, 3); assert.ok(terminals[0].solutions.every((s) => s.mode === 'discharge' && s.power > 0)); }); + + it('throttles discharge according to per-phase command limits', async () => { + const { graph } = buildGraph(); + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ]); + + const msg = { + batteries: oneVenusEPerPhase([{ soc: 90 }, { soc: 90 }, { soc: 90 }]), + grid_power: 6000, + advanced_settings: {}, + phase_protection: { + enabled: true, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 3000, L2: 3000, L3: 3000 }, + discharge: { L1: 3000, L2: 3000, L3: 3000 }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Self-consumption', msg); + + assert.equal(terminals.length, 1); + const solutions = terminals[0].solutions; + assert.equal(solutions.length, 3); + assert.ok(solutions.every((s) => s.mode === 'discharge' && s.power > 0)); + // Each battery gets no more than its phase discharge limit. + assert.ok(solutions.every((s) => s.power <= 3000)); + }); + + it('redistributes unused phase charge allowance to other batteries on the same phase', async () => { + const { graph } = buildGraph(); + const state = new Map([ + ['input_boolean.house_battery_grid_power_has_limit_import', 'off'], + ['input_boolean.house_battery_grid_power_has_limit_export', 'off'], + ]); + + // First battery on each phase is at 99 % SoC and limited to 1000 W; second + // battery can use the remaining phase budget. + const batteries = twoVenusEPerPhase({ + 0: { soc: 99 }, + 2: { soc: 99 }, + 4: { soc: 99 }, + }); + + const msg = { + batteries, + grid_power: -12000, + advanced_settings: {}, + phase_protection: { + enabled: true, + command_limits_available: true, + command_limit_by_phase: { + charge: { L1: 3000, L2: 3000, L3: 3000 }, + discharge: { L1: null, L2: null, L3: null }, + }, + }, + }; + + graph.stateProvider = new StateProvider(state); + const terminals = await graph.run('Self-consumption', msg); + + assert.equal(terminals.length, 1); + const solutions = terminals[0].solutions; + assert.equal(solutions.length, 6); + assert.ok(solutions.every((s) => s.mode === 'charge')); + for (let i = 0; i < 6; i += 2) { + assert.equal(solutions[i].power, 1000); + assert.equal(solutions[i + 1].power, 2000); + } + }); }); diff --git a/test/integration/strategy-sell.test.js b/test/integration/strategy-sell.test.js index 152cbff..1193a56 100644 --- a/test/integration/strategy-sell.test.js +++ b/test/integration/strategy-sell.test.js @@ -92,11 +92,11 @@ describe('Sell strategy integration', () => { assert.equal(terminals.length, 1); const solutions = terminals[0].solutions; - assert.equal(solutions[0].power, 2500); - assert.equal(solutions[1].power, 500); - assert.equal(solutions[2].power, 2500); - assert.equal(solutions[3].power, 500); - assert.equal(solutions[4].power, 2500); - assert.equal(solutions[5].power, 500); + assert.equal(solutions[0].power, 1500); + assert.equal(solutions[1].power, 1500); + assert.equal(solutions[2].power, 1500); + assert.equal(solutions[3].power, 1500); + assert.equal(solutions[4].power, 1500); + assert.equal(solutions[5].power, 1500); }); }); diff --git a/test/lib/flow-graph.js b/test/lib/flow-graph.js index 9993324..2cd9e69 100644 --- a/test/lib/flow-graph.js +++ b/test/lib/flow-graph.js @@ -237,7 +237,7 @@ class FlowGraph { } _evalSwitch(node, msg) { - const property = RED.getMessageProperty(msg, node.property); + const property = this._getProperty(node.propertyType || 'msg', node.property, msg); for (let i = 0; i < (node.rules || []).length; i++) { const rule = node.rules[i]; const check = this._evalSwitchRule(rule, property, msg); diff --git a/test/support/init-flow.js b/test/support/init-flow.js index 86e97a0..521fe47 100644 --- a/test/support/init-flow.js +++ b/test/support/init-flow.js @@ -31,10 +31,15 @@ class Initializer { /** * Provide a no-op logger global for tests that don't need the real one. + * This still runs `initialize()` so that `phasePowerAllocator` and other + * globals created by the "Custom logger" node are available, then silences + * logging. */ useNoopLogger() { + this.initialize(); this.global.set('logger', () => {}); this.global.set('unhandledException', () => {}); + return this; } } diff --git a/test/unit/phase/allocator.test.js b/test/unit/phase/allocator.test.js new file mode 100644 index 0000000..8bdd060 --- /dev/null +++ b/test/unit/phase/allocator.test.js @@ -0,0 +1,199 @@ +'use strict'; + +const { describe, it } = require('node:test'); +const assert = require('node:assert/strict'); +const { Initializer } = require('../../support/init-flow'); + +describe('phasePowerAllocator', () => { + function allocator() { + const initializer = new Initializer(); + initializer.initialize(); + return initializer.global.get('phasePowerAllocator'); + } + + it('exposes the expected phases', () => { + const pa = allocator(); + assert.deepEqual(pa.phases, ['L1', 'L2', 'L3']); + }); + + describe('getPhase', () => { + it('maps known phases to uppercase and unknown to unassigned', () => { + const pa = allocator(); + assert.equal(pa.getPhase({ phase: 'L1' }), 'L1'); + assert.equal(pa.getPhase({ phase: 'l2' }), 'L2'); + assert.equal(pa.getPhase({ phase: 'unknown' }), 'unassigned'); + assert.equal(pa.getPhase({ phase: '' }), 'unassigned'); + assert.equal(pa.getPhase({}), 'unassigned'); + }); + }); + + describe('getResponsivePowerLimit', () => { + it('returns the requested power for a normal charge request', () => { + const pa = allocator(); + const battery = { soc: 50, soc_max: 100, soc_min: 10, power: 0 }; + assert.equal(pa.getResponsivePowerLimit(battery, 2500, 'charge'), 2500); + }); + + it('returns zero when charge would exceed SoC max', () => { + const pa = allocator(); + const battery = { soc: 100, soc_max: 100, soc_min: 10, power: 0 }; + assert.equal(pa.getResponsivePowerLimit(battery, 2500, 'charge'), 0); + }); + + it('returns zero when discharge would fall below SoC min', () => { + const pa = allocator(); + const battery = { soc: 10, soc_max: 100, soc_min: 10, power: 0 }; + assert.equal(pa.getResponsivePowerLimit(battery, 2500, 'discharge'), 0); + }); + + it('caps charge power when the battery is near full and underusing a sustained command', () => { + const pa = allocator(); + const tenSecondsAgo = Date.now() - 10_000; + const battery = { + soc: 97, + soc_max: 100, + soc_min: 10, + power: 800, + last_command: { mode: 'charge', power: 2500, since: tenSecondsAgo }, + }; + assert.equal(pa.getResponsivePowerLimit(battery, 2500, 'charge'), 900); + }); + + it('does not cap charge power before sustained underuse when not near full', () => { + const pa = allocator(); + const twoSecondsAgo = Date.now() - 2_000; + const battery = { + soc: 80, + soc_max: 100, + soc_min: 10, + power: 800, + last_command: { mode: 'charge', power: 2500, since: twoSecondsAgo }, + }; + assert.equal(pa.getResponsivePowerLimit(battery, 2500, 'charge'), 2500); + }); + + it('ignores unrelated past command modes when deciding underuse', () => { + const pa = allocator(); + const tenSecondsAgo = Date.now() - 10_000; + const battery = { + soc: 50, + soc_max: 100, + soc_min: 10, + power: 800, + last_command: { mode: 'discharge', power: 2500, since: tenSecondsAgo }, + }; + assert.equal(pa.getResponsivePowerLimit(battery, 2500, 'charge'), 2500); + }); + }); + + describe('allocateWaterFill', () => { + it('distributes a tight budget evenly across equal capacities', () => { + const pa = allocator(); + const items = [{ capacity: 2500 }, { capacity: 2500 }, { capacity: 2500 }]; + const result = pa.allocateWaterFill(items, 3000, (item) => item.capacity); + assert.equal(result.get(items[0]), 1000); + assert.equal(result.get(items[1]), 1000); + assert.equal(result.get(items[2]), 1000); + }); + + it('caps smaller items and redistributes to larger ones', () => { + const pa = allocator(); + const items = [{ capacity: 1000 }, { capacity: 2500 }, { capacity: 2500 }]; + const result = pa.allocateWaterFill(items, 5000, (item) => item.capacity); + // first item takes 1000, remaining 4000 split between the other two = 2000 each + assert.equal(result.get(items[0]), 1000); + assert.equal(result.get(items[1]), 2000); + assert.equal(result.get(items[2]), 2000); + }); + + it('returns zero for every item when the budget is zero', () => { + const pa = allocator(); + const items = [{ capacity: 2500 }, { capacity: 2500 }]; + const result = pa.allocateWaterFill(items, 0, (item) => item.capacity); + assert.equal(result.get(items[0]), 0); + assert.equal(result.get(items[1]), 0); + }); + }); + + describe('allocatePriorityWaterFill', () => { + it('serves the priority item before regular items', () => { + const pa = allocator(); + const items = [ + { id: 'A', capacity: 2500, is_priority_battery: true }, + { id: 'B', capacity: 2500 }, + ]; + const result = pa.allocatePriorityWaterFill(items, 3000, (item) => item.capacity); + // 3000 budget, priority battery A is served first (water-fill within priority group) + // then B gets remainder + assert.equal(result.get(items[0]), 2500); + assert.equal(result.get(items[1]), 500); + }); + + it('falls back to water-fill when no priority items exist', () => { + const pa = allocator(); + const items = [{ capacity: 2500 }, { capacity: 2500 }]; + const result = pa.allocatePriorityWaterFill(items, 3000, (item) => item.capacity); + assert.equal(result.get(items[0]), 1500); + assert.equal(result.get(items[1]), 1500); + }); + }); + + describe('buildMaxPowerStates', () => { + it('returns requested power when no phase limits apply', () => { + const pa = allocator(); + const batteries = [{ id: 'M1', phase: 'L1', charging_max: 2500 }]; + const states = pa.buildMaxPowerStates({ + batteries, + maxPowerProperty: 'charging_max', + phaseLimits: { L1: Infinity }, + }); + assert.equal(states[0].assignedPower, 2500); + }); + + it('shares a tight phase limit fairly between two batteries', () => { + const pa = allocator(); + const batteries = [ + { id: 'M1', phase: 'L1', charging_max: 2500 }, + { id: 'M2', phase: 'L1', charging_max: 2500 }, + ]; + const states = pa.buildMaxPowerStates({ + batteries, + maxPowerProperty: 'charging_max', + phaseLimits: { L1: 3000 }, + }); + assert.equal(states[0].assignedPower, 1500); + assert.equal(states[1].assignedPower, 1500); + }); + + it('assigns full requested power when the phase budget is sufficient', () => { + const pa = allocator(); + const batteries = [ + { id: 'M1', phase: 'L1', charging_max: 2500 }, + { id: 'M2', phase: 'L1', charging_max: 2500 }, + ]; + const states = pa.buildMaxPowerStates({ + batteries, + maxPowerProperty: 'charging_max', + phaseLimits: { L1: 6000 }, + }); + assert.equal(states[0].assignedPower, 2500); + assert.equal(states[1].assignedPower, 2500); + }); + + it('honours priority-first mode', () => { + const pa = allocator(); + const batteries = [ + { id: 'M1', phase: 'L1', charging_max: 2500, is_priority_battery: true }, + { id: 'M2', phase: 'L1', charging_max: 2500 }, + ]; + const states = pa.buildMaxPowerStates({ + batteries, + maxPowerProperty: 'charging_max', + phaseLimits: { L1: 3000 }, + allocationMode: 'priority-first', + }); + assert.equal(states[0].assignedPower, 2500); + assert.equal(states[1].assignedPower, 500); + }); + }); +}); diff --git a/test/unit/phase/command-limits.test.js b/test/unit/phase/command-limits.test.js index 0bbb07d..a2453a4 100644 --- a/test/unit/phase/command-limits.test.js +++ b/test/unit/phase/command-limits.test.js @@ -4,33 +4,39 @@ const { describe, it } = require('node:test'); const assert = require('node:assert/strict'); const { FunctionRunner } = require('../../lib/function-runner'); const { Initializer } = require('../../support/init-flow'); -const { oneVenusEPerPhase } = require('../../fixtures/homes'); describe('Phase command limits', () => { - it('computes correct charge/discharge limits when readings are within bounds', () => { + function runCommandLimits({ batteries, grid_power_phase, phaseLimit = 5500, enabled = true }) { const initializer = new Initializer(); initializer.useNoopLogger(); - const runner = new FunctionRunner(); + const msg = { - batteries: oneVenusEPerPhase(), - grid_power_phase: { L1: 500, L2: -300, L3: 0 }, - grid_power_limit_phase: 5500, - phase_protection: { enabled: true }, + batteries, + grid_power_phase, + grid_power_limit_phase: phaseLimit, + phase_protection: { enabled }, }; - const result = runner.run({ + return runner.run({ flowFile: 'node-red/01 start-flow.json', node: 'Phase command limits', msg, global: initializer.global, }); + } + + it('computes correct charge/discharge limits when readings are within bounds', () => { + const result = runCommandLimits({ + batteries: [ + { id: 'M1', phase: 'L1', power: 0 }, + { id: 'M2', phase: 'L2', power: 0 }, + { id: 'M3', phase: 'L3', power: 0 }, + ], + grid_power_phase: { L1: 500, L2: -300, L3: 0 }, + }); - assert.equal( - result.phase_protection.command_limits_available, - true, - 'limits should be available' - ); + assert.equal(result.phase_protection.command_limits_available, true); assert.deepEqual(result.phase_protection.command_limit_by_phase.charge, { L1: 5000, L2: 5800, @@ -44,22 +50,9 @@ describe('Phase command limits', () => { }); it('marks limits unavailable when a phase sensor is missing', () => { - const initializer = new Initializer(); - initializer.useNoopLogger(); - - const runner = new FunctionRunner(); - const msg = { - batteries: oneVenusEPerPhase(), + const result = runCommandLimits({ + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], grid_power_phase: { L1: 500, L2: null, L3: 0 }, - grid_power_limit_phase: 5500, - phase_protection: { enabled: true }, - }; - - const result = runner.run({ - flowFile: 'node-red/01 start-flow.json', - node: 'Phase command limits', - msg, - global: initializer.global, }); assert.equal(result.phase_protection.command_limits_available, false); @@ -71,24 +64,66 @@ describe('Phase command limits', () => { }); it('marks limits unavailable when phase protection is disabled', () => { - const initializer = new Initializer(); - initializer.useNoopLogger(); - - const runner = new FunctionRunner(); - const msg = { - batteries: oneVenusEPerPhase(), + const result = runCommandLimits({ + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], grid_power_phase: { L1: 500, L2: -300, L3: 0 }, - grid_power_limit_phase: 5500, - phase_protection: { enabled: false }, - }; - - const result = runner.run({ - flowFile: 'node-red/01 start-flow.json', - node: 'Phase command limits', - msg, - global: initializer.global, + enabled: false, }); assert.equal(result.phase_protection.command_limits_available, false); }); + + describe('across home personas', () => { + it('single battery with no phase assignment still computes neutral limits', () => { + const result = runCommandLimits({ + batteries: [{ id: 'M1', phase: 'unassigned', power: 0 }], + grid_power_phase: { L1: 500, L2: -300, L3: 0 }, + }); + assert.equal(result.phase_protection.command_limits_available, true); + assert.deepEqual(result.phase_protection.command_limit_by_phase.charge, { + L1: 5000, + L2: 5800, + L3: 5500, + }); + }); + + it('two batteries per phase subtracts their signed contribution from the reading', () => { + const result = runCommandLimits({ + batteries: [ + { id: 'M1', phase: 'L1', power: 1000 }, + { id: 'M2', phase: 'L1', power: 500 }, + { id: 'M3', phase: 'L2', power: -200 }, + { id: 'M4', phase: 'L2', power: -100 }, + { id: 'M5', phase: 'L3', power: 0 }, + { id: 'M6', phase: 'L3', power: 0 }, + ], + grid_power_phase: { L1: 2000, L2: -1000, L3: 0 }, + }); + + assert.equal(result.phase_protection.command_limits_available, true); + // L1 reading 2000 - battery contribution 1500 = 500 underlying + assert.equal(result.phase_protection.command_limit_by_phase.charge.L1, 5000); + assert.equal(result.phase_protection.command_limit_by_phase.discharge.L1, 6000); + // L2 reading -1000 - battery contribution -300 = -700 underlying + assert.equal(result.phase_protection.command_limit_by_phase.charge.L2, 6200); + assert.equal(result.phase_protection.command_limit_by_phase.discharge.L2, 4800); + }); + + it('heterogeneous battery mix still produces limits for each phase', () => { + const result = runCommandLimits({ + batteries: [ + { id: 'M1', phase: 'L3', power: 0 }, + { id: 'M2', phase: 'L3', power: 0 }, + { id: 'M3', phase: 'L2', power: 0 }, + { id: 'M4', phase: 'L1', power: 0 }, + { id: 'M5', phase: 'L3', power: 0 }, + ], + grid_power_phase: { L1: 500, L2: -300, L3: 100 }, + }); + assert.equal(result.phase_protection.command_limits_available, true); + assert.equal(result.phase_protection.command_limit_by_phase.charge.L1, 5000); + assert.equal(result.phase_protection.command_limit_by_phase.charge.L2, 5800); + assert.equal(result.phase_protection.command_limit_by_phase.charge.L3, 5400); + }); + }); }); diff --git a/test/unit/phase/protection-guard.test.js b/test/unit/phase/protection-guard.test.js index 4f0e504..0aafb66 100644 --- a/test/unit/phase/protection-guard.test.js +++ b/test/unit/phase/protection-guard.test.js @@ -6,105 +6,155 @@ const { FunctionRunner } = require('../../lib/function-runner'); const { Initializer } = require('../../support/init-flow'); describe('Phase protection guard', () => { - it('preempts Charge to Standby/peak shave on an import overload', () => { + function runGuard({ target, batteries, grid_power_phase, phaseLimit = 5500 }) { const initializer = new Initializer(); initializer.useNoopLogger(); const runner = new FunctionRunner(); const msg = { - target: 'Charge', - batteries: [ - { id: 'M1', phase: 'L1', power: 0 }, - { id: 'M2', phase: 'L2', power: 0 }, - ], - grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, - grid_power_limit_phase: 5500, + target, + batteries, + grid_power_phase, + grid_power_limit_phase: phaseLimit, phase_protection: { enabled: true }, }; - const result = runner.run({ + return runner.run({ flowFile: 'node-red/01 start-flow.json', node: 'Phase protection guard', msg, global: initializer.global, }); + } + + describe('preempts direct strategies on an import overload', () => { + it('Charge', () => { + const result = runGuard({ + target: 'Charge', + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], + grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, + }); + assert.equal(result.target, 'Standby / peak shave'); + assert.equal(result.phase_protection.preempted_strategy, 'Charge'); + }); - assert.equal(result.target, 'Standby / peak shave'); - assert.equal(result.phase_protection.preempted_strategy, 'Charge'); + it('Sell', () => { + const result = runGuard({ + target: 'Sell', + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], + grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, + }); + assert.equal(result.target, 'Standby / peak shave'); + assert.equal(result.phase_protection.preempted_strategy, 'Sell'); + }); + + it('Dynamic 2', () => { + const result = runGuard({ + target: 'Dynamic 2', + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], + grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, + }); + assert.equal(result.target, 'Standby / peak shave'); + assert.equal(result.phase_protection.preempted_strategy, 'Dynamic 2'); + }); }); - it('does not preempt Full stop', () => { - const initializer = new Initializer(); - initializer.useNoopLogger(); - const runner = new FunctionRunner(); + describe('preempts direct strategies on an export overload', () => { + it('Charge', () => { + const result = runGuard({ + target: 'Charge', + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], + grid_power_phase: { L1: -6000, L2: 0, L3: 0 }, + }); + assert.equal(result.target, 'Standby / peak shave'); + }); - const msg = { + it('Sell', () => { + const result = runGuard({ + target: 'Sell', + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], + grid_power_phase: { L1: -6000, L2: 0, L3: 0 }, + }); + assert.equal(result.target, 'Standby / peak shave'); + }); + }); + + it('does not preempt Full stop', () => { + const result = runGuard({ target: 'Full stop', - batteries: [ - { id: 'M1', phase: 'L1', power: 0 }, - ], + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, - grid_power_limit_phase: 5500, - phase_protection: { enabled: true }, - }; - - const result = runner.run({ - flowFile: 'node-red/01 start-flow.json', - node: 'Phase protection guard', - msg, - global: initializer.global, }); - assert.equal(result.target, 'Full stop'); }); it('does nothing when phase sensors are missing', () => { - const initializer = new Initializer(); - initializer.useNoopLogger(); - const runner = new FunctionRunner(); - - const msg = { + const result = runGuard({ target: 'Charge', - batteries: [ - { id: 'M1', phase: 'L1', power: 0 }, - ], + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], grid_power_phase: { L1: 6000, L2: null, L3: 0 }, - grid_power_limit_phase: 5500, - phase_protection: { enabled: true }, - }; - - const result = runner.run({ - flowFile: 'node-red/01 start-flow.json', - node: 'Phase protection guard', - msg, - global: initializer.global, }); - assert.equal(result.target, 'Charge'); }); - it('does nothing when no battery has a phase assigned', () => { - const initializer = new Initializer(); - initializer.useNoopLogger(); - const runner = new FunctionRunner(); - - const msg = { - target: 'Charge', - batteries: [ - { id: 'M1', phase: 'unassigned', power: 0 }, - ], - grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, - grid_power_limit_phase: 5500, - phase_protection: { enabled: true }, - }; - - const result = runner.run({ - flowFile: 'node-red/01 start-flow.json', - node: 'Phase protection guard', - msg, - global: initializer.global, - }); - - assert.equal(result.target, 'Charge'); + describe('across home personas', () => { + const cases = [ + { + name: 'single battery with no phase assigned', + batteries: [{ id: 'M1', phase: 'unassigned', power: 0 }], + expectPreempt: false, + reason: 'no phase has an assigned battery', + }, + { + name: 'single battery on the overloaded phase', + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], + expectPreempt: true, + }, + { + name: 'one battery per phase', + batteries: [ + { id: 'M1', phase: 'L1', power: 0 }, + { id: 'M2', phase: 'L2', power: 0 }, + { id: 'M3', phase: 'L3', power: 0 }, + ], + expectPreempt: true, + }, + { + name: 'two batteries per phase', + batteries: [ + { id: 'M1', phase: 'L1', power: 0 }, + { id: 'M2', phase: 'L1', power: 0 }, + { id: 'M3', phase: 'L2', power: 0 }, + { id: 'M4', phase: 'L2', power: 0 }, + { id: 'M5', phase: 'L3', power: 0 }, + { id: 'M6', phase: 'L3', power: 0 }, + ], + expectPreempt: true, + }, + { + name: 'no battery on the overloaded phase', + batteries: [ + { id: 'M1', phase: 'L2', power: 0 }, + { id: 'M2', phase: 'L3', power: 0 }, + ], + expectPreempt: false, + reason: 'L1 has no assigned battery', + }, + ]; + + for (const { name, batteries, expectPreempt, reason } of cases) { + it(name, () => { + const result = runGuard({ + target: 'Charge', + batteries, + grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, + }); + if (expectPreempt) { + assert.equal(result.target, 'Standby / peak shave'); + } else { + assert.equal(result.target, 'Charge', reason || 'expected no preempt'); + } + }); + } }); });