EMHASS: An Energy Management for Home Assistant

Since today it seems I have troubles with v0.17, although I did not change anything in the past days.

/action/publish-data leads to in internal server error:

[2026-03-19 07:35:00,571] INFO in web_server:  >> Obtaining params: 
[2026-03-19 07:35:00,575] INFO in web_server:  >> Setting input data dict
[2026-03-19 07:35:00,575] INFO in command_line: Setting up needed data
[2026-03-19 07:35:00,586] INFO in retrieve_hass: get HA config from rest api.
[2026-03-19 07:35:00,606] INFO in web_server:  >> Publishing data...
[2026-03-19 07:35:00,607] INFO in command_line: Publishing data to HASS instance
[2026-03-19 07:35:00,623] ERROR in app: Exception on request POST /action/publish-data
Traceback (most recent call last):
  File "/app/.venv/lib/python3.12/site-packages/pandas/core/indexes/base.py", line 3641, in get_loc
    return self._engine.get_loc(casted_key)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "pandas/_libs/index.pyx", line 168, in pandas._libs.index.IndexEngine.get_loc
  File "pandas/_libs/index.pyx", line 197, in pandas._libs.index.IndexEngine.get_loc
  File "pandas/_libs/hashtable_class_helper.pxi", line 7668, in pandas._libs.hashtable.PyObjectHashTable.get_item
  File "pandas/_libs/hashtable_class_helper.pxi", line 7676, in pandas._libs.hashtable.PyObjectHashTable.get_item
KeyError: 'P_Load'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
  File "/app/.venv/lib/python3.12/site-packages/quart/app.py", line 1464, in handle_request
    return await self.full_dispatch_request(request_context)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/quart/app.py", line 1502, in full_dispatch_request
    result = await self.handle_user_exception(error)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/quart/app.py", line 1059, in handle_user_exception
    raise error
  File "/app/.venv/lib/python3.12/site-packages/quart/app.py", line 1500, in full_dispatch_request
    result = await self.dispatch_request(request_context)
             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/quart/app.py", line 1597, in dispatch_request
    return await self.ensure_async(handler)(**request_.view_args)  # type: ignore[return-value]
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/src/emhass/web_server.py", line 657, in action_call
    msg, status = await _handle_action_dispatch(
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/src/emhass/web_server.py", line 506, in _handle_action_dispatch
    _ = await publish_data(input_data_dict, logger)
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/src/emhass/command_line.py", line 2350, in publish_data
    cols_published.extend(await _publish_standard_forecasts(ctx, opt_res_latest))
                          ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/src/emhass/command_line.py", line 2046, in _publish_standard_forecasts
    opt_res_latest["P_Load"],
    ~~~~~~~~~~~~~~^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/pandas/core/frame.py", line 4378, in __getitem__
    indexer = self.columns.get_loc(key)
              ^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/app/.venv/lib/python3.12/site-packages/pandas/core/indexes/base.py", line 3648, in get_loc
    raise KeyError(key) from err
KeyError: 'P_Load'

moreover, I tried an update on v0.17.1, hoping to fix it, but it did not work either:

2026-03-19 06:45:03.614 INFO (MainThread) [supervisor.docker.addon] Updating image ghcr.io/davidusb-geek/emhass:v0.17.0 to ghcr.io/davidusb-geek/emhass:v0.17.1
2026-03-19 06:45:04.170 WARNING (MainThread) [supervisor.docker.manifest] Platform linux/arm64 not found in manifest list for ghcr.io/davidusb-geek/emhass, cannot use manifest for progress tracking
2026-03-19 06:45:04.171 INFO (MainThread) [supervisor.docker.interface] Downloading docker image ghcr.io/davidusb-geek/emhass with tag v0.17.1.
2026-03-19 06:45:06.161 ERROR (MainThread) [supervisor.docker.manager] no matching manifest for linux/arm64 in the manifest list entries
2026-03-19 06:45:06.167 ERROR (MainThread) [supervisor.addons.addon] Could not pull image to update addon 5b918bf2_emhass: no matching manifest for linux/arm64 in the manifest list entries

Additional information:

it seems it has a problem with 20 <> 21

[2026-03-19 09:00:05,364] INFO in web_server:  >> Obtaining params: 
[2026-03-19 09:00:05,369] INFO in web_server: Passed runtime parameters: {'set_def_constant': [True, False], 'pv_power_forecast': [13808.3, 17010.2, 20297.899999999998, 22593.0, 24916.2, 26201.699999999997, 27169.699999999997, 27563.0, 27060.4, 26285.699999999997, 24567.6, 21914.7, 19235.600000000002, 16485.0, 13160.7, 9570.9, 4959.4, 468.0, 0.0, 0.0], 'def_total_hours': [4.5, 0], 'prediction_horizon': 20, 'load_cost_forecast': [0.18672, 0.18672, 0.18662, 0.18662, 0.18652, 0.18652, 0.18642, 0.18642, 0.18632, 0.18632, 0.18642, 0.18642, 0.18652, 0.18652, 0.18662, 0.18662, 0.18672, 0.18672, 0.18682, 0.18682], 'prod_price_forecast': [0.0798, 0.0798, 0.0797, 0.0797, 0.0796, 0.0796, 0.0795, 0.0795, 0.0794, 0.0794, 0.0795, 0.0795, 0.0796, 0.0796, 0.0797, 0.0797, 0.0798, 0.0798, 0.0799, 0.0799]}
[2026-03-19 09:00:05,370] INFO in web_server:  >> Setting input data dict
[2026-03-19 09:00:05,371] INFO in command_line: Setting up needed data
[2026-03-19 09:00:05,382] INFO in retrieve_hass: get HA config from rest api.
[2026-03-19 09:00:05,443] INFO in retrieve_hass: Using REST API for data retrieval
[2026-03-19 09:00:05,444] INFO in retrieve_hass: Retrieve hass get data method initiated...
[2026-03-19 09:00:09,811] INFO in forecast: Retrieving weather forecast data using method = list
[2026-03-19 09:00:09,818] INFO in forecast: Retrieving data from hass for load forecast using method = naive
[2026-03-19 09:00:09,820] INFO in retrieve_hass: Using REST API for data retrieval
[2026-03-19 09:00:09,820] INFO in retrieve_hass: Retrieve hass get data method initiated...
[2026-03-19 09:00:12,262] INFO in web_server:  >> Performing naive-mpc-optim...
[2026-03-19 09:00:12,262] INFO in command_line: Performing naive MPC optimization
[2026-03-19 09:00:12,339] INFO in optimization: Perform an iteration of a naive MPC controller
[2026-03-19 09:00:12,340] WARNING in optimization: MPC Prediction Horizon (20) does not match the initialized optimization window (21). This may cause shape mismatch errors in the solver.
[2026-03-19 09:00:12,344] INFO in optimization: Resizing optimization problem from 21 to 20 timesteps.
[2026-03-19 09:00:12,358] INFO in optimization: Building CVXPY problem structure...
[2026-03-19 09:00:12,557] WARNING in optimization: Optimization failed with status: 'infeasible'. Retrying with relaxed constraints (Continuous LP)...
[2026-03-19 09:00:12,570] INFO in optimization: Solving relaxed problem (LP)...
[2026-03-19 09:00:12,691] ERROR in optimization: Relaxed optimization also failed with status: infeasible
[2026-03-19 09:00:12,694] WARNING in optimization: Cost function cannot be evaluated or Infeasible/Unbounded
[2026-03-19 09:00:12,691] ERROR in optimization: Relaxed optimization also failed with status: infeasible
[2026-03-19 09:00:12,694] WARNING in optimization: Cost function cannot be evaluated or Infeasible/Unbounded

this is passed to naive-mpc-optim:

        "set_def_constant": [true,false],
        "pv_power_forecast": [13808.3, 17010.2, 20297.899999999998, 22593.0, 24916.2, 26201.699999999997, 27169.699999999997, 27563.0, 27060.4, 26285.699999999997, 24567.6, 21914.7, 19235.600000000002, 16485.0, 13160.7, 9570.9, 4959.4, 468.0, 0.0, 0.0],
        "def_total_hours": [4.5,0],
        "prediction_horizon": 20,
        "load_cost_forecast": [0.18672, 0.18672, 0.18662, 0.18662, 0.18652, 0.18652, 0.18642, 0.18642, 0.18632, 0.18632, 0.18642, 0.18642, 0.18652, 0.18652, 0.18662, 0.18662, 0.18672, 0.18672, 0.18682, 0.18682],
        "prod_price_forecast": [0.0798, 0.0798, 0.0797, 0.0797, 0.0796, 0.0796, 0.0795, 0.0795, 0.0794, 0.0794, 0.0795, 0.0795, 0.0796, 0.0796, 0.0797, 0.0797, 0.0798, 0.0798, 0.0799, 0.0799]

I fixed my problem…

The reason was, that my solcast values exceeded the maximum power I configured. I changed that later, but I forgot to restart the App…

but the update problem v0.17.0 → v0.17.1 is still there… (RPi 4 + Home Assistant OS)

Yes, there was a problem generating images for ARM devices. It is now solved, you can try the update again

The problem still persists…
Do I need to refresh something?

You can try now, it should work!

Forum Post Draft — EMHASS Community Thread

Title: Battery dispatch automation for SolarEdge — what’s actually working?


Hi all,

I’m running EMHASS with naive MPC (every 5 min) on a SolarEdge SE10000H + 2Ɨ 9.7kWh batteries, Amber Electric in Sydney (Ausgrid network). Control via solaredge-modbus-multi, PV forecasts from Solcast. I pass Amber prices, Solcast PV, and an external LightGBM load forecast (which I can share if anyone is interested) as runtime lists. The optimisation runs, produces schedules, publishes p_batt_forecast / p_grid_forecast etc. All good so far.

The part I’m not confident about is the automation that actually tells the inverter what to do.

1. Mapping p_batt_forecast to SolarEdge storage commands

You can set charge/discharge power limits via Modbus, and pick a storage command mode for direction. But p_batt_forecast is based on forecasted conditions that don’t match reality exactly.

Example: EMHASS plans a 2kW charge, I set the charge limit to 2kW, but actual solar comes in at 3kW. The battery caps at 2kW and the extra 1kW goes to grid at a rubbish FiT.

What I’m doing now: using p_batt_forecast sign for direction, p_grid_forecast to decide whether grid charging was part of the plan, and storage_discharge_limit to cap discharge rate. On Amber with Ausgrid network tariffs, import costs significantly more than export pays (the gap varies by time of day), so getting the charge direction wrong is not cheap.

How are other SolarEdge users handling this? Setting explicit limits from EMHASS, or just picking a mode and letting the inverter sort it out?

2. What do you do when Solcast is wrong?

MPC re-runs every 5 minutes, but Solcast only updates a handful of times a day. Cloud rolls in, actual PV drops to half the forecast, and now the inverter is pulling from grid to maintain a charge rate that assumed solar surplus.

I’ve set alpha=0.25, beta=0.75 to blend recent actuals into the near-term forecast, which takes the edge off. But I’m wondering if anyone adds real-time guards in their automation (checking actual PV before allowing grid charge, that kind of thing), or if the consensus is just to let MPC replan and accept the occasional bad 5-minute window.

Setup:

  • SolarEdge SE10000H, 2Ɨ ~9.7kWh batteries, 16.3kW solar (3 roof faces)
  • Amber Electric (wholesale + hedging, 5-min settlement, Ausgrid)
  • EMHASS v0.17, Docker standalone
  • solaredge-modbus-multi v3.2.2 via HACS
  • Solcast hobbyist (2 sites, 10 API calls/day)
  • Home Assistant 2026.3

Would especially like to hear from other Amber / wholesale pricing users dealing with the same import/export tariff asymmetry.

Thanks!

Almost identical setup, you sure you’re not me…
10KW Solaredge Inverter with the same 2x batteries on Amber/Ausgrid, but slightly less solar panels.

  1. I use EMHASS to determine whether it should be charging, discharging etc, solar only but leave the import/export at the max. Then I run MPC every minute, so if it discharges/charges at 8KW for a few mins instead of 4KW for a few more I’m generally not too worried.
  2. No realtime guards, I just let MPC replan as it needs to.

Interesting. Do you not find it imports from grid for load?

Eg you leave import limit on max and set ā€œcharge from solarā€

Your solar is pumping out 2kW, load is another 1kW

It will charge at 2kW and import the 1?

No, I think I have 2 things that help avoid that.

  1. I rarely set the system to ā€œCharge from Solarā€ most of the time my system is using Maximise Self-Consumption during the day. That way the system is powering the house and then putting the rest into the battery. I’ve had a look back through the last week and I’ve not set to ā€œCharge from Solarā€ once.
  2. I have added a weight_battery_discharge of 0.04 - my understanding is that it helps the system not charge / discharge indiscriminately as the system makes sure I’m making at least 0.04c on an export/discharge it won’t consider exporting

Makes sense. Do you find this setup deals with Amber price spikes well (eg will it conserve battery / charge from grid in advance of a spike)?

Also do you feed any actuals into your emhass MPC? (Eg use actual load, actual pv at time zero so that it’s never wrong for that block and then it replans like that every minute)?

i use the amber2mqtt to try and get the quickest amber pricing at the start of each 5 min block and has dealt with the spikes pretty well (though they are not happening as frequently these days).
I also feed in actual load, actual solar, actual SOC etc in for each run.

Ok that makes sense. Last question - what rule do you use to determine the inverter charge setting? P-batt?

This is the logic that I use to map the info from EMHASS to what I want to do with the Inverter, which I then use to drive the inverter changes. There’s been a little tweaking to avoid some of charge / discharge dancing. It was based off of this entry above - EMHASS: An Energy Management for Home Assistant - #881 by bakerboy908. It’s been pretty reliable to me.

I then have some other logic around curtailment based on current pricing, and as i’m on a demand tarrif I set the price for power import to $99 between 3pm and 9pm during summer and winter months.

- sensor:
    - unique_id: emhass_mode
      name: "EMHASS Mode"
      state: >-
        {%set bat_power = states('sensor.p_batt_forecast') | int(0) %}
        {%set load = states('sensor.p_load_forecast') | int(0) %}
        {%set grid = states('sensor.p_grid_forecast') | int(0) %}
        {%set solar = states('sensor.p_pv_forecast') | int(0) %}
        {%set feed_price = states('sensor.amber_5min_current_feed_in_price') | float(0) %}

        {% if states('sensor.solaredge_i1_device') == 'unavailable' %}
          Self_consume
        {% elif bat_power < -750 and grid > 750 %}
          Charge 
        {% elif bat_power == 0 and grid < 0 %}
          Export_solar 
        {% elif grid == load and solar == 0 and bat_power == 0 %}
          Export_solar 
        {% elif bat_power > 0 and grid < -750 and feed_price > 0 %}
          Discharge
        {%elif bat_power <= 0 and grid > 500 %}
          Backup 
        {%else %} 
          Self_consume 
        {%- endif %}

Thank you, extremely helpful! Mine is a bit different but putting them side by side helped me work through the kinks.

1 Like

This is my battery automation

alias: EMHASS battery
id: 66809bb1-c454-43a5-899a-c354840b11b6
triggers:
  - trigger: state
    entity_id: sensor.emhass_battery
  - trigger: state
    entity_id: input_boolean.emhass_gridfollower
    from: "on"
    to: "off"
  - trigger: homeassistant
    event: start
    id: start

conditions:
  # Controleer of EMHASS sensoren beschikbaar zijn
  - condition: template
    value_template: >
      {{ states("sensor.emhass_battery") not in ['unavailable', 'unknown'] }}
  - condition: template
    value_template: >
      {{ states("sensor.emhass_grid2") not in ['unavailable', 'unknown'] }}

variables:
  p_batt: >-
    {{ states("sensor.emhass_battery") }}

  p_grid: >-
    {{ states("sensor.emhass_grid2") }}

  # Huidige SOC van batterij
  soc: "{{ states('sensor.battery_state_of_charge') | float(0) }}"

  # Bepaal EMHASS batterij modus met originele logica
  emhass_battery_mode: >-
    {% if p_batt == 'unavailable' or p_grid == 'unavailable' -%}
      Unknown
    {% else -%}
      {% set p_batt_val = p_batt | float(0) -%}
      {% set p_grid_val = p_grid | float(0) -%}
      {% set soc_val = soc | float(0) -%}
      {% if p_grid_val == 0  -%}
        {% if p_batt_val < 0  -%}
          Forcible Charge
        {% else -%}
          Follow Grid
        {% endif -%}
      {% elif p_batt_val == 0 -%}
        Forcible Discharge
      {% elif p_batt_val < 0 -%}
        {% if soc_val >= 95 -%}
          Idle
        {% else -%}
          Forcible Charge
        {% endif -%}
      {% elif p_batt_val > 0 -%}
        {% if soc_val <= 10 -%}
          Idle
        {% else -%}
          Forcible Discharge
        {% endif -%}
      {% else -%}
        Unknown
      {% endif -%}
    {% endif -%}

  # Bereken power percentage voor GoodWe (alleen bij force charge/discharge)
  power_percentage: >-
    {% set abs_power = (p_batt | float) | abs -%}
    {% if emhass_battery_mode in ['Forcible Discharge', 'Forcible Charge'] -%}
      {% if abs_power > 8000 -%}
        100
      {% else -%}
        {{ (abs_power / 80) | round(0) }}
      {% endif -%}
    {% else -%}
      0
    {% endif -%}

actions:
  # Stel GoodWe batterij mode in op basis van EMHASS mode
  - choose:
      # FORCIBLE CHARGE
      - conditions:
          - condition: template
            value_template: "{{ emhass_battery_mode == 'Forcible Charge' }}"
          - condition: state
            entity_id: script.emhass_grid_follower
            state: "off"
        sequence:
          # Controleer of mode wijziging nodig is
          - if:
              - condition: template
                value_template: "{{ states('select.goodwe_inverter_operation_mode') != 'eco_charge' }}"
            then:
              - action: select.select_option
                target:
                  entity_id: select.goodwe_inverter_operation_mode
                data:
                  option: "eco_charge"
          # Controleer of power wijziging nodig is
          - if:
              - condition: template
                value_template: "{{ (states('number.goodwe_eco_mode_power') | float) != (power_percentage | float) }}"
            then:
              - action: number.set_value
                target:
                  entity_id: number.goodwe_eco_mode_power
                data:
                  value: "{{ power_percentage }}"
          # Start de grid-follower regelaar (mode: single → start niet opnieuw als al actief)
          - if:
              - condition: state
                entity_id: input_boolean.emhass_gridfollower
                state: "on"
            then:
              - action: script.turn_on
                target:
                  entity_id: script.emhass_grid_follower

      # FORCIBLE DISCHARGE
      - conditions:
          - condition: template
            value_template: "{{ emhass_battery_mode == 'Forcible Discharge' }}"
          - condition: state
            entity_id: script.emhass_grid_follower
            state: "off"
        sequence:
          # Controleer of mode wijziging nodig is
          - if:
              - condition: template
                value_template: "{{ states('select.goodwe_inverter_operation_mode') != 'eco_discharge' }}"
            then:
              - action: select.select_option
                target:
                  entity_id: select.goodwe_inverter_operation_mode
                data:
                  option: "eco_discharge"

          # Controleer of power wijziging nodig is
          - if:
              - condition: template
                value_template: "{{ (states('number.goodwe_eco_mode_power') | float) != (power_percentage | float) }}"
            then:
              - action: number.set_value
                target:
                  entity_id: number.goodwe_eco_mode_power
                data:
                  value: "{{ power_percentage }}"
          # Start de grid-follower regelaar (mode: single → start niet opnieuw als al actief)
          - if:
              - condition: state
                entity_id: input_boolean.emhass_gridfollower
                state: "on"
            then:
              - action: script.turn_on
                target:
                  entity_id: script.emhass_grid_follower

      # FOLLOW GRID / IDLE (general mode)
      - conditions:
          - condition: template
            value_template: "{{ emhass_battery_mode in ['Follow Grid', 'Idle'] }}"
        sequence:
          # Controleer of mode wijziging nodig is
          - if:
              - condition: template
                value_template: "{{ states('select.goodwe_inverter_operation_mode') != 'general' }}"
            then:
              - action: select.select_option
                target:
                  entity_id: select.goodwe_inverter_operation_mode
                data:
                  option: "general"
              - action: number.set_value
                target:
                  entity_id: number.goodwe_eco_mode_power
                data:
                  value: 5

    default:
      - action: logbook.log
        data:
          name: "EMHASS GoodWe Control"
          message: "Onbekende EMHASS mode: {{ emhass_battery_mode }}"

mode: restart

And this is my grid_follower script

emhass_grid_follower:
  alias: EMHASS Grid Follower
  sequence:
    - repeat:
        while:
          - condition: template
            value_template: >
              {{ states('select.goodwe_inverter_operation_mode') in ['eco_charge', 'eco_discharge'] }}
        sequence:
          - variables:
              p_grid_actual: "{{ states('sensor.huidig_fluvius') | float(0) }}"
              p_grid_target: "{{ states('sensor.emhass_grid2') | float(0) }}"
              current_step: "{{ states('number.goodwe_eco_mode_power') | float(0) }}"
              current_mode: "{{ states('select.goodwe_inverter_operation_mode') }}"
              error: "{{ p_grid_actual - p_grid_target }}"
              max_charge_pct: >
                {% set w = states('sensor.battery_p_charge_max') | float(8000) -%}
                {{ [(w / 80) | round(0) | int, 100] | min }}
              max_discharge_pct: >
                {% set w = states('sensor.battery_p_discharge_max') | float(4448) -%}
                {{ [(w / 80) | round(0) | int, 100] | min }}

              # eco_charge:    more power → more grid consumption → error decreases
              #                negative error (actual < target) → increase power
              #                positive error (actual > target) → decrease power
              # eco_discharge: more power → less grid consumption → error decreases
              #                positive error (actual > target) → increase power
              #                negative error (actual < target) → decrease power
              # Conclusion: sign of correction is OPPOSITE per mode
              corrected_error: >
                {% if current_mode == 'eco_charge' -%}
                  {{ -(error | float) }}
                {% else -%}
                  {{ error | float }}
                {% endif -%}

              # Larger hysteresis: only switch mode when deviation >300W
              # and only if current_step is already at minimum (0-5%)
              # This prevents oscillation between charge/discharge
              target_mode: >
                {% set e = error | float -%}
                {% set cs = current_step | float -%}
                {% if current_mode == 'eco_discharge' -%}
                  {% if e < -100 and cs <= 5 -%}
                    eco_charge
                  {% else -%}
                    eco_discharge
                  {% endif -%}
                {% elif current_mode == 'eco_charge' -%}
                  {% if e > 100 and cs <= 5 -%}
                    eco_discharge
                  {% else -%}
                    eco_charge
                  {% endif -%}
                {% else -%}
                  {{ current_mode }}
                {% endif -%}

              # Step size: proportional but capped at max 15% per step
              step: >
                {{ [(( corrected_error | abs) / 80) | round(0) | int, 15] | min }}

              new_step: >
                {% set ce = corrected_error | float -%}
                {% set s = step | int -%}
                {% set cs = current_step | float -%}
                {% set soc = states('sensor.battery_state_of_charge') | float(50) -%}
                {% set soc_block = (current_mode == 'eco_discharge' and soc <= 15) or (current_mode == 'eco_charge' and soc >= 95) -%}
                {% set max_pct = max_charge_pct | int if current_mode == 'eco_charge' else max_discharge_pct | int -%}
                {% if target_mode != current_mode or soc_block -%}
                  {% set ns = 0 -%}
                {% elif ce | abs <= 50 -%}
                  {% set ns = cs -%}
                {% elif ce > 0 -%}
                  {% set ns = cs + s -%}
                {% else -%}
                  {% set ns = cs - s -%}
                {% endif -%}
                {% if ns > max_pct -%}
                  {{ max_pct }}
                {% elif ns < 0 -%}
                  0
                {% else -%}
                  {{ ns | round(0) | int }}
                {% endif -%}

              is_night: >
                {% set h = now().hour -%}
                {{ h >= 1 and h < 6 }}

              interval: "{{ 60 if is_night else 10 }}"

          # Switch mode if necessary
          - if:
              - condition: template
                value_template: "{{ target_mode != current_mode }}"
            then:
              - action: number.set_value
                target:
                  entity_id: number.goodwe_eco_mode_power
                data:
                  value: 0
              - delay:
                  seconds: 1
              - action: select.select_option
                target:
                  entity_id: select.goodwe_inverter_operation_mode
                data:
                  option: "{{ target_mode }}"

          # Adjust step if mode does not switch
          - if:
              - condition: template
                value_template: "{{ target_mode == current_mode and (new_step | int) != (current_step | int) }}"
            then:
              - action: number.set_value
                target:
                  entity_id: number.goodwe_eco_mode_power
                data:
                  value: "{{ new_step }}"

          - delay:
              seconds: "{{ interval }}"

  mode: single
  max_exceeded: silent

Very interesting approach!
I am comparing to the way I implemented battery automation for Goodwe ET inverter and I see few differences.
There must be a good reason why you choose to go for grid follower script and not just to use Operation mode = General so that inverter logic ā€œfollowsā€ the grid. What is it?

I’ve seen that general mode from goodwe keeps the grid at 0 when there is no/not much solar, if there is more solar, general mode doesn’t keep me grid at 0.
So if p_grid_val == 0 and p_batt_val < 0 → Forcible Charge

When I have Forcible Charge or Forcible Discharge I use my grid_follower script
If there is more solar then predicted → goes to the battery
If there is more usage then predicted → power comes from the battery not from the grid

Ok, my goal is a bit different - its to achieve rapid reaction to spot prices change (MTU = 15 min) and fluctuatins in PV and load side and to ensure maximum economic effect, so I always want to force charge / discharge at maximum system power.
I also was completely satisfied with the inverter General operation mode behaviour.
With my automation I only try to detect force (Eco) charge, force (Eco) discharge or 100% PV export modes, if not detected - automation resolves to General .
I run MPC optimization every minute.

Have just started testing EMHASS with my Sigen system and now I have an issue. Hoping someone has had the same issue and can help!
EMS seems to be working fine and doing what it should, but I have now disabled EMS mode and put it back in to self consumption mode, but it now won’t use my stored battery and continuously takes from the grid.
When I put it in manual control and self consumption it takes from the battery fine, so it seems like an issue with the self consumption profile.
Has anyone had an issue like this before?