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