diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 689fa65..0000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 3565fd3..adf601a 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,18 @@ RELEASE_NOTES_TEMP.txt # Ignore notes and development files notes/ template_sensor_bob_battery_dashboard.yaml +.DS_Store + +# Ignore opencode local workspace +.opencode/ -# Ignore SASS cache files 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/ \ No newline at end of file +.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/docs/06-advanced-features.md b/docs/06-advanced-features.md index 0e17303..e7f2be7 100644 --- a/docs/06-advanced-features.md +++ b/docs/06-advanced-features.md @@ -64,6 +64,12 @@ 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 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`. + - 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. @@ -94,6 +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. 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. 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/.DS_Store b/home assistant/.DS_Store deleted file mode 100644 index 8c1d6d6..0000000 Binary files a/home assistant/.DS_Store and /dev/null differ diff --git a/home assistant/dashboard.yaml b/home assistant/dashboard.yaml index d661ddf..45c4db5 100644 --- a/home assistant/dashboard.yaml +++ b/home assistant/dashboard.yaml @@ -410,9 +410,30 @@ views: columns: full rows: 1 - type: heading - heading: Marstek M1 + heading: Battery Phase Interaction heading_style: subtitle - icon: '' + 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 + name: L1 + - entity: sensor.house_battery_l2_power + name: L2 + - 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 %} + text_only: true visibility: - condition: state entity: sensor.marstek_m1_device_name @@ -431,10 +452,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: @@ -463,10 +484,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: @@ -495,10 +516,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: @@ -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: @@ -559,10 +580,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: @@ -1848,6 +1869,32 @@ 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_boolean.house_battery_control_has_phase_protection + - entity: input_number.house_battery_control_max_phase_power + name: Max phase power + - type: markdown + content: >- + 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 - type: grid cards: - type: heading @@ -1890,6 +1937,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 +2010,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 +2079,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 +2148,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 +2217,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 +2286,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..b7b8d63 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 @@ -388,6 +392,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 +674,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 +918,98 @@ template: {# Marstek uses negative power when delivering power, positive when charging. This is inverse from what HA expects.#} {{ (total_power * -1) | round(2) }} + # 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 AC 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_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m2_phase') == 'L1' %} + {% 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_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m4_phase') == 'L1' %} + {% 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_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m6_phase') == 'L1' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_ac_power') | float(0)) %} + {% endif %} + {{ ((total_w.value * -1) / 1000) | round(2) }} + + # Live AC 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_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m2_phase') == 'L2' %} + {% 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_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m4_phase') == 'L2' %} + {% 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_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m6_phase') == 'L2' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_ac_power') | float(0)) %} + {% endif %} + {{ ((total_w.value * -1) / 1000) | round(2) }} + + # Live AC 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_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m2_phase') == 'L3' %} + {% 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_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m4_phase') == 'L3' %} + {% 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_ac_power') | float(0)) %} + {% endif %} + {% if states('input_select.marstek_m6_phase') == 'L3' %} + {% set total_w.value = total_w.value + (states('sensor.marstek_m6_ac_power') | float(0)) %} + {% endif %} + {{ ((total_w.value * -1) / 1000) | round(2) }} + # Time Remaining - name: "Battery time remaining" unique_id: house_battery_time_remaining @@ -959,6 +1101,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 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 3f899e2..d81417c 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, @@ -317,11 +318,17 @@ "f6f3ce29833ec4cf", "5ac3cdf0ad6013e3", "69d4aa99b46127ba", - "783deb36dab080de" + "783deb36dab080de", + "9eaf5b01ad50102c", + "9eaf5b01ad50102d", + "9eaf5b01ad50102e", + "9eaf5b01ad50102f", + "9eaf5b01ad501030", + "9eaf5b01ad501032" ], "x": 34, "y": 999, - "w": 752, + "w": 2106, "h": 82 }, { @@ -567,7 +574,7 @@ "wires": [ [ "415a53493b19f461", - "a18697cfe0c15963" + "9eaf5b01ad501031" ] ] }, @@ -675,7 +682,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\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, @@ -1247,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, @@ -1542,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, @@ -1932,7 +1939,7 @@ "y": 500, "wires": [ [ - "43bc77e1a735d1d4" + "9eaf5b01ad50102a" ] ] }, @@ -1942,7 +1949,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, @@ -2536,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 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, @@ -3262,7 +3269,7 @@ "y": 1040, "wires": [ [ - "f7e09807696115eb" + "9eaf5b01ad50102c" ] ] }, @@ -3567,5 +3574,224 @@ "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": [ + [ + "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": [ + [ + "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": [ + [ + "9eaf5b01ad501032" + ] + ] + }, + { + "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 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, + "initialize": "", + "finalize": "", + "libs": [], + "x": 720, + "y": 1100, + "wires": [ + [ + "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" + ] + ] } -] \ No newline at end of file +] diff --git a/node-red/02 strategy-charge.json b/node-red/02 strategy-charge.json index adc69ad..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\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\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, @@ -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 48e5327..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 * \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 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, @@ -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..cf916a9 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);\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, @@ -2388,4 +2388,4 @@ "node-red-node-smooth": "0.1.2" } } -] \ No newline at end of file +] diff --git a/node-red/02 strategy-sell.json b/node-red/02 strategy-sell.json index b94e138..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\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\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, @@ -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 2ffe90b..c3ce3d4 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, @@ -1342,11 +1343,17 @@ "a6203334d934a28a", "d8d5fbfc33432345", "783d88edb917dab9", - "f496519e76d1c4fa" + "f496519e76d1c4fa", + "9eaf5b01ad50102c", + "9eaf5b01ad50102d", + "9eaf5b01ad50102e", + "9eaf5b01ad50102f", + "9eaf5b01ad501030", + "9eaf5b01ad501032" ], "x": 34, "y": 999, - "w": 752, + "w": 2106, "h": 82 }, { @@ -3001,7 +3008,7 @@ "wires": [ [ "641a1632b399f7a6", - "78d74d259961a7ef" + "9eaf5b01ad501031" ] ] }, @@ -3109,7 +3116,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\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, @@ -3681,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, @@ -3976,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, @@ -4366,7 +4373,7 @@ "y": 500, "wires": [ [ - "fea927e3388ed2c9" + "9eaf5b01ad50102a" ] ] }, @@ -4376,7 +4383,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, @@ -4970,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 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, @@ -5696,7 +5703,7 @@ "y": 1040, "wires": [ [ - "2c2ed88ff158d0b7" + "9eaf5b01ad50102c" ] ] }, @@ -6164,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 * \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 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, @@ -6214,7 +6221,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, @@ -6252,7 +6259,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, @@ -6609,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\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, @@ -13177,7 +13184,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, @@ -13241,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 = 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);\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, @@ -13881,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\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, @@ -16037,5 +16044,224 @@ "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": [ + [ + "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": [ + [ + "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": [ + [ + "9eaf5b01ad501032" + ] + ] + }, + { + "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 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, + "initialize": "", + "finalize": "", + "libs": [], + "x": 720, + "y": 1100, + "wires": [ + [ + "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" + ] + ] } -] \ No newline at end of file +] 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..885e495 --- /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..8e54b6a --- /dev/null +++ b/test/integration/strategy-charge.test.js @@ -0,0 +1,174 @@ +'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, + singleVenusEThrottled, + oneVenusEPerPhase, + twoVenusEPerPhase, + heterogeneousSystem, +} = require('../fixtures/homes'); + +describe('Charge strategy integration', () => { + function runCharge(home, overrides = {}) { + 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: home, + 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 }, + }, + }, + ...overrides, + }; + + graph.stateProvider = new StateProvider(state); + 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('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)); + }); + + 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); + }); + + 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); + }); + + 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); + }); + + 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); + const solutions = terminals[0].solutions; + // 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-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..3bffedc --- /dev/null +++ b/test/integration/strategy-partials.test.js @@ -0,0 +1,181 @@ +'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, + oneVenusEPerPhase, + twoVenusEPerPhase, +} = require('../fixtures/homes'); + +describe('Partials strategy integration', () => { + function buildGraph() { + 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', + ]); + 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'], + ['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, + 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 { 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'], + ['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, + 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)); + }); + + 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 new file mode 100644 index 0000000..a1a27e1 --- /dev/null +++ b/test/integration/strategy-self-consumption.test.js @@ -0,0 +1,158 @@ +'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, + oneVenusEPerPhase, + twoVenusEPerPhase, +} = require('../fixtures/homes'); + +describe('Self-consumption strategy integration', () => { + function buildGraph() { + 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']); + 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'], + ]); + + 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 { graph } = buildGraph(); + 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)); + }); + + 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 new file mode 100644 index 0000000..1193a56 --- /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, 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/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..2cd9e69 --- /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 = 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); + 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..521fe47 --- /dev/null +++ b/test/support/init-flow.js @@ -0,0 +1,46 @@ +'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. + * 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; + } +} + +module.exports = { Initializer }; 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 new file mode 100644 index 0000000..a2453a4 --- /dev/null +++ b/test/unit/phase/command-limits.test.js @@ -0,0 +1,129 @@ +'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 command limits', () => { + function runCommandLimits({ batteries, grid_power_phase, phaseLimit = 5500, enabled = true }) { + const initializer = new Initializer(); + initializer.useNoopLogger(); + const runner = new FunctionRunner(); + + const msg = { + batteries, + grid_power_phase, + grid_power_limit_phase: phaseLimit, + phase_protection: { enabled }, + }; + + 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); + 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 result = runCommandLimits({ + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], + grid_power_phase: { L1: 500, L2: null, L3: 0 }, + }); + + 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 result = runCommandLimits({ + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], + grid_power_phase: { L1: 500, L2: -300, L3: 0 }, + 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 new file mode 100644 index 0000000..0aafb66 --- /dev/null +++ b/test/unit/phase/protection-guard.test.js @@ -0,0 +1,160 @@ +'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', () => { + function runGuard({ target, batteries, grid_power_phase, phaseLimit = 5500 }) { + const initializer = new Initializer(); + initializer.useNoopLogger(); + const runner = new FunctionRunner(); + + const msg = { + target, + batteries, + grid_power_phase, + grid_power_limit_phase: phaseLimit, + phase_protection: { enabled: true }, + }; + + 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'); + }); + + 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'); + }); + }); + + 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'); + }); + + 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 }], + grid_power_phase: { L1: 6000, L2: 0, L3: 0 }, + }); + assert.equal(result.target, 'Full stop'); + }); + + it('does nothing when phase sensors are missing', () => { + const result = runGuard({ + target: 'Charge', + batteries: [{ id: 'M1', phase: 'L1', power: 0 }], + grid_power_phase: { L1: 6000, L2: null, L3: 0 }, + }); + 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'); + } + }); + } + }); +}); 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 }, + ]); + }); +});