Dynamic ESS replaced by Home Assistent

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.
  1. 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:

  1. 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:

  1. Solcast Threshold: 15kWh standard.

  2. Soc_high_with_little_sun: 50% standard.

  3. 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.

  1. 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:

LLMs are fascinating, aren’t they?

What you have created, although, is a rule-based engine - from what i gathered by skimming that textdump (not a fan of that, like most people, really).

Did you consider any of the alternatives to DESS (and i’m specifically not talking about “if x then y” based automations) and if so, which?

…mind you, i am a fan of creating their own thing for them to fully enjoy, customize, understand and use - selling that as superior to DESS.. don’t know…

You have made an interesting tool. It is probably fine-tuned for your household, but that is the great thing about being tek savvy :)))

dESS misses explanation to its decisions, that is true. I think that it is clear on what is planning for future but looking at the past it is confusing sometimes. But for my sites, it works well most of the times. What was the issues for you @joostvanderwulp

I understand your frustration. Dynamic ESS sometimes looks strange when you watch it in real time. I had the same impression initially. However, after several months of monitoring my system, I noticed that many decisions only make sense when you evaluate the full 24–48 hour result instead of a single hour.

My system has moved energy between days, delayed exports to higher-price periods, adjusted battery targets dynamically and achieved results that would have been difficult to reproduce manually.

I agree that more transparency would be welcome, but in my experience the final outcome is often better than the real-time behavior suggests.

"if x then y” based automations

This quote caught my attention. How does an automation work without if and then?

What I was trying to get at: a „not user programmed state machine“, i.e. an automation system that does predictions and learns from past behavior - much like DESS does, but isn’t DESS.