I am not satisfied with the Victron Dynamic ESS module.
It does strange things: during the day when prices are high, suddenly consuming a lot, and so on.
I think the entire Victron ecosystem is wonderful: open, adaptable, modular, understandable, local, without Chinese or American servers.
But that Dynamic ESS Module?? That is a kind of mysterious black magic box. You have no insight into what it is going to do, or why it is doing something. And you can’t adjust anything on it. And it doesn’t work on your local network either.
In Flanders, less than 1% of homes have a dynamic energy contract. I soon noticed that apparently no one in Flanders is interested in this. That is a missed opportunity; combining a dynamic energy contract with a home battery is the future!!
Driven by this dissatisfaction, I started looking for a solution myself to smartly automate injecting and drawing from the grid. I became captivated by Home Assistant. It is a wonderful application, which is very suitable for performing this automation.
I would like to hear your comments and suggestions, and whether there are people who would also like to implement it. It could perhaps be improved a lot?
System Components:
Hardware:
• 16x EVE MB31 with JK BMS via CAN bus.
• Victron CerboGX via MQTT
• 3x Victron Multiplus-II 48/3000
• Victron Bluesolar MPPT 250/85
• 15 x Aiko Neostar 2 PV Panels of 455Wp
• Chuwi Larkbox minicomputer with Intel N100 and 2TB NVME
Software:
• HomeAssistant on Proxmox
• HomeAssistant Victron MQTT Addon
• Engie Dynamic Contract, prices per quarter hour
• EnTSO-E account with personal API
• HomeAssistant ENTSO-E Addon
• Solcast account with personal API
• HomeAssistant Solcast Addon
Objective:
Use smart control to:
- inject excess energy during expensive periods.
- and purchase energy deficits during cheap periods.
- using the dynamic pricing of the Dynamisch Engie contract.
- and the solar yield forecast from Solcast.
- Sensors, Buttons, and Logic
1.1. The sensors
1.1.1. The Battery SOC indicator
“sensor.bms_op_vertical_eelbattery_id_512_battery_charge”.
1.1.2. The Energy Prices:
“sensor.engie_dynamisch_injectieprijs”. This is the live ENTSO-E feed containing an attribute (prices) with the 96 quarter-hourly prices for the current day (converted to Euro/kWh). The ENGIE price formulas must then be calculated on top of this.
Procedure:
-
Create an account with ENTSO-E. That is an online platform that provides energy prices every fifteen minutes. https://transparency.entsoe.eu/
-
Request an API via email.
-
Install the ENTSO-E addon in HA.
-
Now we still need to add the Engie contract formulas I have to the prices.
We do this using a template.
This template creates the sensors: “Engie Dynamic Consumption Price” and “Engie Dynamic Injection Price”. These are used in the automation.
Note: the ENTSO-E addon outputs different entities. You need the: “sensor.vanderwulp_beerse_average_electricity_price”. This sensor outputs an average daily price as a state value. Which is of no use to us. However, in its state attributes, it provides the entire list of the 96 fifteen-minute values for injection and consumption. Those are what we need! We therefore need to program a data generator to retrieve the state attributes. In HA, go to settings → Developer tools → states. Then, in the top box: “state”, type your sensor name: “sensor.vanderwulp_beerse_average_electricity_price”. You will then see all the data. You can copy the exact naming convention here.
1.1.3. The Solar Forecast:
“sensor.solcast_pv_forecast_forecast_tomorrow”. Determines how many kilowatt-hours (kWh) are expected to be generated the following day.
Procedure:
-
Create an account with SOLCAST.
-
Retrieve your personal API via the API toolkit. Enter the location, orientation, and tilt angle of your panels.
-
Install the SOLCAST Addon in HA.
-
The main entity is: sensor.solcast_pv_forecast_forecast_tomorrow
1.1.4. Note:
I did not make use of the fluctuations in household consumption. This is fairly constant on a daily basis for us. Over a 24-hour period, there are large fluctuations, but the battery handles that. So I concluded that the data on household consumption only makes things unnecessarily complicated and adds little value.
1.2. The operated Victron buttons:
1.2.1. DC-coupled PV - feed in excess
“switch.gx_device_pv_dc_overvoltage_feed_in”
With this, you can switch the injection of excess power on or off.
1.2.2. Grid AC power setpoint
“number.gx_device_ac_power_setpoint”
With this setting, you can adjust the balance between injection and consumption from the grid. Normally this is set to 0W, but you can also perfectly set it to -6000. If a load of 2000W suddenly appears somewhere in the house, the ESS system adjusts it to 4000W injection. We will therefore use this to force injection or consumption.
1.3. Use and operation of Home Assistant
How can we build this in Home Assistant?
We use three systems in Home Assistant:
the Template Sensor displays
A template is a small program that looks at parameters or sensors, performs calculations on them, and then displays the end result by creating new sensors.
You create templates in yaml, write them in ninja, and place them in the folder: configuration.yaml.
To keep things organized, I have placed the code in a separate folder: “templates”. And in “configuration.yaml” I specified: “template: !include_dir_merge_list templates/”
All files in this folder (with the .yaml extension!) are now included.
The Helper remembers
an “input_number_helper” is a button that remembers a number.
You can create and manage the helpers at: settings → devices and services → helpers.
The Automation thinks and acts
The automation reads the entities from the templates, the numbers from the helpers, then calculates certain conclusions, and executes them.
You can manage and create the automations at: settings → automation & Scenes → Automations.
1.4. The logic:
First, as simple a representation of the logic as possible:
We are going to split the automation into different parts:
Part 1: We use three threshold values:
- SOC Low With Lots of Sun: default 35%
- SOC High With Little Sun: default 50%
- SOC Winter Limit: default 80%
Determine the Battery SOC switching values based on sun forecast:
For Injection:
IF Solcast Tomorrow < 15 kWh, THEN: Soc_high_with_little_sun (50%)
IF Solcast Tomorrow > 15 kWh, THEN: Soc_low_with_lots_of_sun (35%)
For Consumption:
IF Solcast tomorrow <6 kWh, THEN: Soc_winter_limit (80%). Part 2: We determine when the system is allowed to inject or draw power by defining two statuses:
Power_price_is_in_Evening_Peak
Power_price_is_in_Winter_Off-Peak
With the following logic:
IF the most expensive 8 quarters AND the price is > €0.10, THEN Evening_Peak = ON
IF the cheapest 8 quarters AND Solcast < 6kWh, THEN Winter_Off-Peak = ON
Part 3: the actual automation:
IF: Evening_Peak = ON, AND: SOC-Low = ON, THEN: Injection -4500W
IF: Evening_Peak = ON, AND: SOC-High = ON, THEN: Injection -4500W
IF: Winter_Off-Peak = ON, AND: SOC-Winter_Limit = ON, THEN: Draw-off 4500W
Part 4: Blocking injection if nothing yields.
By default, the system injects excess power into the grid. However, this is not always beneficial; sometimes prices are too low or even negative. In such cases, the solar panels simply need to be switched off. We do this by turning the Victron button: “DC-coupled PV - feed in excess “switch.gx_device_pv_dc_overvoltage_feed_in” ON or OFF.
IF: injection price < €0.10 THEN: PV Grid Feed-in = OFF
The logic then looks like this:
- The Templates and automations
2.1. The templates:
With 2 templates, we already perform a pre-calculation that determines a status.
Template 1. engie_dynamische_tarieven.yaml
This template takes the ENTSO-E prices and reconciles them with the Engie rates from my dynamic contract. It creates two new entities with these new values: “Engie Dynamic Purchase Price”, and: “Engie Dynamic Injection Price”.
- sensor:
- name: "Engie Dynamisch Afnameprijs"
unique_id: "engie_dynamisch_afname_prijs"
unit_of_measurement: "EUR/kWh"
device_class: monetary
state: >
{% set p = states('sensor.vanderwulp_beerse_average_electricity_price') | float(0) %}
{% if p > 0 %}
{{ ((p * 1.019 + 0.013163) * 1.06 + 0.0106 + 0.01582) | round(3) }}
{% else %}
0.0
{% endif %}
attributes:
prices: >
{% set source_prices = state_attr('sensor.vanderwulp_beerse_average_electricity_price', 'prices_today') %}
{% if source_prices is not none %}
{% set ns = namespace(prices=[]) %}
{% for item in source_prices %}
{% set p = item.price | float(0) %}
{% set calc = ((p * 1.019 + 0.013163 ) * 1.06 + 0.0106 + 0.01582 ) | round(3) %}
{% set ns.prices = ns.prices + [calc] %}
{% endfor %}
{{ ns.prices }}
{% else %}
[]
{% endif %}
- name: "Engie Dynamisch Injectieprijs"
unique_id: "engie_dynamisch_injectieprijs"
unit_of_measurement: "EUR/kWh"
device_class: monetary
state: >
{% set p = states('sensor.vanderwulp_beerse_average_electricity_price') | float(0) %}
{{ (p - 0.013050) | round(4) }}
attributes:
prices: >
{% set source_prices = state_attr('sensor.vanderwulp_beerse_average_electricity_price', 'prices_today') %}
{% if source_prices is not none %}
{% set ns = namespace(prices=[]) %}
{% for item in source_prices %}
{% set p = item.price | float(0) %}
{% set calc = (p - 0.013050) | round(3) %}
{% set ns.prices = ns.prices + [calc] %}
{% endfor %}
{{ ns.prices }}
{% else %}
[]
{% endif %}
Template 2. Victron_Dynamische_Minimum_SOC.yaml
This template looks at two values: the Battery SOC and the Solcast solar forecast for tomorrow. It defines the following values:
-
Solcast Threshold: 15kWh standard.
-
Soc_high_with_little_sun: 50% standard.
-
Soc_low_with_sun: 35% standard.
Based on this data, it creates a new entity: “victron_applied_min_soc”
Furthermore, this template determines the best times for injection and withdrawal. It looks at the entities with the Engie dynamic rates. Since a dynamic contract has a different price every 15 minutes, there are 96 prices for withdrawal per 24-hour period, and 96 for injection.
It looks for the 8 most expensive 15-minute periods for injection, and the 8 cheapest for withdrawal.
Why 8 15-minute periods (or 2 hours total)? Because I have a 16.3 kW battery and can inject a maximum of 6000W, and the battery can be discharged from roughly 90% to 35% for injection, injecting from the battery never takes more than two hours.
This template defines two states. These can be “ON” or “OFF”:
Stroomprijs_is_in_Avondpiek
Stroomprijs_is_in_Winter_Off-Peak-Quarter
Furthermore, there are two new entities in this template: these serve to nicely display the status of injection and consumption in a graph:
status_injectie_sensor: “0”, or: “1” (an APEX Chart cannot handle “ON” and “OFF”)
status_afverbruik_sensor: ¨0¨, or: “1”
And finally, the “stroomprijs_is_in_avondpiek” has a fairly extensive attribute; “quarters”. This serves to display the selected 8 quarters in a small table on the dashboard for information. They have no influence on the automation.
- sensor:
- name: "Victron Toegepaste Minimum SoC"
unique_id: victron_toegepaste_min_soc
unit_of_measurement: "%"
availability: "{{ states('sensor.solcast_pv_forecast_forecast_tomorrow') | is_number }}"
state: >
{% set solcast_morgen = states('sensor.solcast_pv_forecast_forecast_tomorrow') | float(0) %}
{% set drempel = states('input_number.drempel_solcast') | float(15) %}
{% set soc_hoog = states('input_number.soc_hoog_bij_weinig_zon') | float(50) %}
{% set soc_laag = states('input_number.soc_laag_bij_zon') | float(35) %}
{{ soc_hoog if solcast_morgen < drempel else soc_laag }}
- name: "Stroomprijs is in Avondpiek"
unique_id: stroomprijs_is_in_avondpiek
state: >
{% set prices = state_attr('sensor.engie_dynamisch_injectieprijs', 'prices') or [] %}
{% if prices | length == 96 %}
{% set huidig_kwartier_index = (now().hour * 4) + (now().minute // 15) %}
{% set huidige_prijs = prices[huidig_kwartier_index] | float(0) %}
{% set sorted_prices = prices | map('float') | sort(reverse=true) | list %}
{% set drempel_prijs = sorted_prices[7] if sorted_prices | length >= 8 else 0.25 %}
{{ 'on' if huidige_prijs >= drempel_prijs and huidige_prijs >= states('input_number.minimum_prijs') | float(0) else 'off' }} {% else %}
off
{% endif %}
attributes:
kwartieren: >
{% set prices = state_attr('sensor.engie_dynamisch_injectieprijs', 'prices') or [] %}
{% if prices | length == 96 %}
{% set sorted_prices = prices | map('float') | sort(reverse=true) | list %}
{% set drempel_prijs = sorted_prices[7] %}
{% set results = namespace(list=[]) %}
{% for i in range(96) %}
{% if prices[i] | float >= drempel_prijs and prices[i] | float >= 0.10 %}
{% set uur = (i // 4) | string %}
{% set minuut = ((i % 4) * 15) | string %}
{% set tijd = (uur.zfill(2) ~ ':' ~ minuut.zfill(2)) %}
{% set results.list = results.list + [tijd ~ ' (' ~ prices[i] ~ '€)'] %}
{% endif %}
{% endfor %}
{{ results.list | join(', ') }}
{% else %}
Geen data
{% endif %}
- name: "Stroomprijs is in Winter-Dalkwartier"
unique_id: stroomprijs_is_in_winter_dalkwartier
state: >
{% set prices = state_attr('sensor.engie_dynamisch_injectieprijs', 'prices') or [] %}
{% set solcast_morgen = states('sensor.solcast_pv_forecast_forecast_tomorrow') | float(0) %}
{% if prices | length == 96 and solcast_morgen < 6 %}
{% set huidig_kwartier_index = (now().hour * 4) + (now().minute // 15) %}
{% set huidige_prijs = prices[huidig_kwartier_index] | float(0) %}
{% set sorted_prices = prices | map('float') | sort(reverse=false) | list %}
{% set drempel_prijs = sorted_prices[7] if sorted_prices | length >= 8 else 0.05 %}
{{ 'on' if huidige_prijs <= drempel_prijs else 'off' }}
{% else %}
off
{% endif %}
- name: "Status Injectie"
unique_id: status_injectie_sensor
state: >
{{ 1 if is_state('sensor.stroomprijs_is_in_avondpiek', 'on') else 0 }}
- name: "Status Afname"
unique_id: status_afname_sensor
state: >
{{ 1 if is_state('sensor.stroomprijs_is_in_winter_dalkwartier', 'on') else 0 }}
2.2. The automations:
Automation 1: Victron: Dynamic Battery Management (All-Season)
This is the actual automation. It is executed every minute.
alias: "Victron: Dynamisch Batterijbeheer (All-Season)"
description: "Stuurt het AC-setpoint aan: dumpen in de avond, laden in de nacht."
triggers:
- minutes: /1
trigger: time_pattern
conditions: []
actions:
- choose:
- alias: "Prioriteit 1: Avond-dumpen"
conditions:
- condition: state
entity_id: sensor.stroomprijs_is_in_avondpiek
state: "on"
- condition: template
value_template: >
{{
states('sensor.bms_op_vertical_eelbattery_id_512_battery_charge')
| float(0) > states('sensor.victron_toegepaste_minimum_soc_2') |
float(0) }}
sequence:
- action: number.set_value
target:
entity_id: number.gx_device_ac_power_setpoint
data:
value: -4500
- alias: "Prioriteit 2: Nacht/Goedkoop laden"
conditions:
- condition: state
entity_id: sensor.stroomprijs_is_in_winter_dalkwartier
state: "on"
- condition: template
value_template: >
{{
states('sensor.bms_op_vertical_eelbattery_id_512_battery_charge')
| float(0) < states('input_number.soc_winter_limiet') | float(80)
}}
sequence:
- action: number.set_value
target:
entity_id: number.gx_device_ac_power_setpoint
data:
value: 4500
default:
- action: number.set_value
target:
entity_id: number.gx_device_ac_power_setpoint
data:
value: 0
mode: single
Automation 2: Victron Block Injection at Low Price
This automation blocks injection if the price drops below €0.10.
This is executed every 15 minutes, as soon as the prices have changed.
alias: Victron Blokkeer Injectie Bij Lage Prijs
description: Zet injectie aan of uit, met fail-safe naar ON bij storing
triggers:
- minutes: /15
trigger: time_pattern
- event: start
trigger: homeassistant
conditions: []
actions:
- choose:
- conditions:
- condition: template
value_template: >-
{% set drempel = states('input_number.minimum_prijs') |
float(0.10) %} {% set index = (now().hour * 4) + (now().minute //
15) %} {% set prijzen =
state_attr('sensor.engie_dynamisch_injectieprijs', 'prices') %}
{% if prijzen and index < prijzen | length %}
{{ prijzen[index] | float < drempel }}
{% else %} false {% endif %}
sequence:
- action: switch.turn_off
target:
entity_id: switch.gx_device_pv_dc_overvoltage_feed_in
- conditions:
- condition: template
value_template: >-
{% set drempel = states('input_number.minimum_prijs') |
float(0.10) %} {% set index = (now().hour * 4) + (now().minute //
15) %} {% set prijzen =
state_attr('sensor.engie_dynamisch_injectieprijs', 'prices') %} {%
if prijzen and index < prijzen | length %}
{{ prijzen[index] | float >= drempel }}
{% else %} false {% endif %}
sequence:
- action: switch.turn_on
target:
entity_id: switch.gx_device_pv_dc_overvoltage_feed_in
default:
- action: switch.turn_on
target:
entity_id: switch.gx_device_pv_dc_overvoltage_feed_in
mode: single
Notes:
- Default: Normal ESS management
When: If none of the above peaks or dips are active.
Action: Sends a 0 to the AC setpoint. This is the “idle mode”. The Victron inverter stops forcing charge or discharge and returns to its standard ESS mode (where it only compensates for household consumption).
- Monitoring from the dashboard: The controlled buttons of the Victron system:
number.gx_device_ac_power_setpoint.
switch.gx_device_pv_dc_overvoltage_feed_in.
-
are displayed on the Home Assistant main screen. If the power setpoint is at -4500, you know immediately that the automation is successfully injecting. Or if the feed-in is set to OFF, it has prohibited injection because the price is too poor.
-
Maintenance: Please note ! If you change a parameter by moving a helper, that value is not yet updated in the APEX charts. You may need to do that separately there.
- Moving the parameters with the helpers
Quite a few values have been selected. In practice, these may not all turn out to be the correct choices. We therefore want to be able to set them easily from the dashboard. To do this, we are going to replace all parameters with “input number helpers”.
Instead of setting in the automation:
IF: Solcast threshold <15 kWh, THEN: Min SOC = 35%.
We use:
IF: Solcast threshold < { helper-drempel-solcast }, THEN: Min SOC = { Helper min SOC }.
You can place these helpers as sliders on your dashboard. I will put the default values behind them:
Solcast threshold: input_number.drempel_solcast: 15kWh.
Solcast Winter Valley Threshold: input_number.drempel_solcast_winterdal: 6 kWh.
Minimum Price: input_number.minimum_prijs: 0.10€.
SOC High with Low Sun: input_number.soc_hoog_bij_weinig_zon: 50%.
SOC Low with High Sun: input_number.soc_laag_bij_zon: 35%.
SOC Winter Limit: input_number.soc_winter_limiet: 80%.
Injection Setpoint: input_number.victron_injectie_setpoint: -4500W.
Consumption Setpoint: input_number.victron_afname_setpoint: 4500W.
Finally: i want to share with you my dashboards.
First i made a replacement dashboard of the Victron GUI, but much more detailed and fancy:
Then i made 4 beautiful graphs:
And finally I made a dashboard of the automation, with al information, and all the adjustable parameters:




