Adaptive Solar Battery Charging in Home Assistant using a Solar Forecast

Thank you, I have a similar installation plus an ev charger. Will try to set this up and test it !

It’s probably so specific to my particular situation that it will be of little value to anyone else.

Can you show us the other tabs for your energy monitoring please. I’m intriqued


For energy monitoring I just have a screen full of multiple entity cards showing power, energy today, energy this month, and percentages of the total for each. The energy totals are from an Emporia Vue and a Shelly EM. I also have power flow and energy flow dashboards but I never look at them. They’re just the standard cards.

The more interesting dashboards (and automations) have to do with importing grid power. I monitor grid voltage and adjust how much power I am importing. This is necessary because our grid is incredibly fragile. It’s very easy to cause the voltage to drop so much that it causes problems.

2 Likes

Hi, I have been working on something very similar to your system. Dynamic level of battery charging and dynamic charge rate so any charging is less stressfull on the battery.

This automation manages battery charging during night rate electricity periods by dynamically calculating the required charge power and target state-of-charge (SOC) based on the solar forecast and time remaining until day rate.

Key features:

  • Uses Solcast solar forecasts to set different SOC targets (e.g. charge less if lots of solar is forecast for tomorrow, charge more if little is forecast).
  • Adjusts force charge power dynamically to ensure the target SOC is reached just before the end of the cheap night rate.
  • Switches inverter modes automatically between Force Charge and Feed-in First depending on the tarrif rates.
  • Sends email notifications with detailed status, forecasts, SOC targets, and inverter readings at key moments (start, update, end, or target reached).
  • Handles edge cases (e.g. backup mode, SOC already above target).

This is version v5 of the automation, de-personalised for sharing. Replace placeholders such as [email protected] and notify.your_email_service with your own details.

My system comprises of the following:-
FoxESS 5kWh Inverter
20.72kWh of Fox battery
17 Solar panels (9 North facing and 8 soth facing)
Modbus integration to HA from the inverter
Solcast solar forcast updated 4 times a day
EON EV charger electricity tarrif that gives 7 hours cheap electricity overnight (night rate is 6.7pKwh)
I receive SEG payments of 16.5pKwh for export
I charge my batteries at night and export all the solar I can during the day after any house use.
I use a calendar in HA to store the tarrif day and night rate times

alias: Battery Charging - Dynamic Night Rate Charge Power v5
description: >
  Automates battery charging during night rate periods based on solar forecast
  and current SOC, using next Day rate from calendar.get_events.

# ───────────────────────────────────────
# TRIGGERS - When should this automation run?
# ───────────────────────────────────────
triggers:
  # Trigger when Solcast solar forecast is updated
  - id: solcast_update
    entity_id: sensor.solcast_pv_forecast_api_last_polled
    trigger: state

  # Trigger at the END of the Night rate (switch to Feed-in First mode)
  - id: night_rate_end
    entity_id: sensor.electricity_current_rate
    from: Night rate
    trigger: state

  # Trigger at the START of the Night rate (start Force Charge mode)
  - id: night_rate_start
    entity_id: sensor.electricity_current_rate
    to: Night rate
    trigger: state

  # Trigger if inverter unexpectedly enters "Back-up" mode (after 1 min)
  - id: backup_work_mode
    trigger: state
    entity_id:
      - select.foxinvertermodbus_work_mode
    to: Back-up
    for:
      minutes: 1

  # Trigger whenever Battery SOC changes
  - id: soc_reached
    trigger: state
    entity_id: sensor.foxinvertermodbus_battery_soc

conditions: []   # No global conditions

# ───────────────────────────────────────
# ACTIONS - What should happen when triggered?
# ───────────────────────────────────────
actions:
  # 1. Get calendar events for the next 2 days (to find next Day rate start)
  - action: calendar.get_events
    target:
      entity_id: calendar.electricity_prices_daily
    data:
      start_date_time: "{{ now().isoformat() }}"
      end_date_time: "{{ (now() + timedelta(days=2)).isoformat() }}"
    response_variable: events_data

  # 2. Define variables for SOC targets, charging power calculations, etc.
  - variables:
      # Mapping forecast ranges (kWh) β†’ target SOC %
      soc_levels:
        - min: 0
          max: 7
          soc: 100
        - min: 7.001
          max: 11
          soc: 96
        - min: 11.001
          max: 15
          soc: 92
        - min: 15.001
          max: 18
          soc: 88
        - min: 18.001
          max: 21
          soc: 84
        - min: 21.001
          max: 25
          soc: 80
        - min: 25.001
          max: 28
          soc: 76
        - min: 28.001
          max: 30
          soc: 72
        - min: 30.001
          max: 68
          soc: 70

      # Min/max allowed charge power (kW)
      min_power_kw: 0.1
      max_power_kw: 5

      # Efficiency uplift factor (accounts for losses)
      efficiency_uplift: 1.3

      # Inputs from sensors
      forecast_today: "{{ states('sensor.solcast_pv_forecast_forecast_today') | float(0) }}"
      current_soc: "{{ states('sensor.foxinvertermodbus_battery_soc') | float(0) }}"
      total_capacity_kwh: 20.72
      now_time: "{{ now() }}"

      # Desired SOC (Force Charge mode)
      desired_soc: >
        {% set forecast = forecast_today %}
        {% set soc = namespace(val=100) %}
        {% for level in soc_levels %}
          {% if forecast >= level.min and forecast <= level.max %}
            {% set soc.val = level.soc %}
          {% endif %}
        {% endfor %}
        {{ soc.val }}

      # Desired SOC (after Night rate ends / normal use)
      desired_soc_self_use: >
        {% set forecast = forecast_today %}
        {% set soc = namespace(val=100) %}
        {% for level in soc_levels %}
          {% if forecast >= level.min and forecast <= level.max %}
            {% set soc.val = level.soc %}
          {% endif %}
        {% endfor %}
        {{ soc.val }}

      # Next "Day rate" time (from calendar) or default 06:01
      target_time: >
        {% set cal_events = events_data['calendar.electricity_prices_daily'].events %}
        {% set matches = cal_events | selectattr('summary', 'search', '(?i)^Day rate') | list %}
        {% set next_event = matches[0] if matches else None %}
        {% if next_event %}
          {{ as_datetime(next_event.start) }}
        {% else %}
          {{ now().replace(hour=6, minute=1, second=0, microsecond=0) }}
        {% endif %}

      # Ensure target time is always in the future
      adjusted_target_time: >
        {% set now_dt = now() %}
        {% set target_dt = as_datetime(target_time) %}
        {% if now_dt > target_dt %}
          {{ target_dt + timedelta(days=1) }}
        {% else %}
          {{ target_dt }}
        {% endif %}

      # Time remaining (hours) until target
      time_remaining_hours: >
        {% set now_dt = now() %}
        {% set adjusted = as_datetime(adjusted_target_time) %}
        {{ ((adjusted - now_dt).total_seconds() / 3600) | round(2) }}

      # SOC difference to reach
      soc_delta: "{{ [desired_soc - current_soc, 0] | max }}"

      # Energy needed (kWh) to reach desired SOC
      energy_needed_kwh: "{{ ((soc_delta / 100) * total_capacity_kwh) | round(2) }}"

      # Required average charge power (kW) within time remaining
      required_power_kw: |
        {% if time_remaining_hours > 0 %}
          {% set raw_power = ((energy_needed_kwh * efficiency_uplift) / time_remaining_hours) | round(2) %}
        {% else %}
          {% set raw_power = 0 %}
        {% endif %}
        {{ [max_power_kw, [min_power_kw, raw_power] | max] | min }}

  # 3. Email configuration (replace with your own notify service)
  - variables:
      email_target: [email protected]
      email_action: notify.your_email_service
      email_message: >-
        Timestamp (UK): {{ now().strftime('%d/%m/%Y %H:%M:%S') }}<br><br>
        Solcast forecast: {{ forecast_today }} kWh<br>
        Current SOC: {{ current_soc }}%<br>
        Desired SOC for Force Charge: {{ desired_soc }}%<br>
        Desired SOC after Night (Feed-in First): {{ desired_soc_self_use }}%<br>
        Inverter Work Mode: {{ states('select.foxinvertermodbus_work_mode') }}<br>
        Battery Temperature: {{ states('sensor.foxinvertermodbus_battery_temp') }}Β°C<br>
        BMS Cell mV Low: {{ states('sensor.foxinvertermodbus_bms_cell_mv_low') }}mV<br>
        BMS Cell mV High: {{ states('sensor.foxinvertermodbus_bms_cell_mv_high') }}mV<br>
        Battery Voltage: {{ states('sensor.foxinvertermodbus_batvolt') }}V<br>
        Battery Current: {{ states('sensor.foxinvertermodbus_bat_current') }}A<br>
        Max Charge Current: {{ states('sensor.foxinvertermodbus_max_charge_current') }}A<br>
        Max Discharge Current: {{ states('sensor.foxinvertermodbus_max_discharge_current') }}A<br>
        Charge Power: {{ states('sensor.foxinvertermodbus_battery_charge') }}W<br>
        Discharge Power: {{ states('sensor.foxinvertermodbus_battery_discharge') }}W<br><br>
        Total capacity: {{ total_capacity_kwh }} kWh<br>
        Target time: {{ as_datetime(target_time).strftime('%A, %d %B %Y %H:%M:%S') }}<br>
        Adjusted target time: {{ as_datetime(adjusted_target_time).strftime('%A, %d %B %Y %H:%M:%S') }}<br>
        Time remaining hours: {{ time_remaining_hours }}<br>
        SOC delta: {{ soc_delta }}%<br>
        Energy needed: {{ energy_needed_kwh }} kWh<br>
        Required power: {{ required_power_kw }} kW<br>
        Current Force Charge Power: {{ states('number.foxinvertermodbus_force_charge_power') | float }} kW<br>
        Min Power Limit: {{ min_power_kw }} kW<br>
        Max Power Limit: {{ max_power_kw }} kW<br>
        Efficiency uplift factor: {{ efficiency_uplift }}<br>
      email_title_start: >
        Battery Force STARTED Charging to {{ desired_soc }}% at {{ required_power_kw }}kW
      email_title_solcast_update: >
        Battery Max SOC changed to {{ desired_soc_self_use }}% at {{ required_power_kw }}kW
      email_title_end: >
        Battery Max SOC changed to {{ desired_soc_self_use }}% at {{ required_power_kw }}kW

  # 4. Choose block - actions depend on the trigger
  - choose:
      # CASE A: Night rate starts β†’ begin Force Charge
      - conditions:
          - condition: trigger
            id: night_rate_start
          - condition: state
            entity_id: sensor.electricity_current_rate
            state: Night rate
        sequence:
          - action: number.set_value
            target:
              entity_id: number.foxinvertermodbus_max_soc
            data:
              value: "{{ current_soc }}"   # Prevents instant stop
          - delay: 5
          - action: select.select_option
            target:
              entity_id: select.foxinvertermodbus_work_mode
            data:
              option: Force Charge
          - action: "{{ email_action }}"
            data:
              title: "{{ email_title_start }}"
              message: "{{ email_message }}"
              target: "{{ email_target }}"

      # CASE B: Solcast update or backup mode β†’ update force charge parameters
      - conditions:
          - condition: trigger
            id:
              - solcast_update
              - backup_work_mode
          - condition: state
            entity_id: sensor.electricity_current_rate
            state: Night rate
          - condition: template
            value_template: "{{ required_power_kw > 0 }}"
        sequence:
          - action: number.set_value
            target:
              entity_id: number.foxinvertermodbus_force_charge_power
            data:
              value: "{{ required_power_kw }}"
          - delay: 5
          - action: number.set_value
            target:
              entity_id: number.foxinvertermodbus_max_soc
            data:
              value: "{{ desired_soc }}"
          - action: select.select_option
            target:
              entity_id: select.foxinvertermodbus_work_mode
            data:
              option: Force Charge
          - action: "{{ email_action }}"
            data:
              title: "{{ email_title_solcast_update }}"
              message: "{{ email_message }}"
              target: "{{ email_target }}"

      # CASE C: Night rate ends β†’ switch back to Feed-in First
      - conditions:
          - condition: trigger
            id: night_rate_end
        sequence:
          - action: select.select_option
            target:
              entity_id: select.foxinvertermodbus_work_mode
            data:
              option: Feed-in First
          - action: "{{ email_action }}"
            data:
              title: "{{ email_title_end }}"
              message: "{{ email_message }}"
              target: "{{ email_target }}"

      # CASE D: SOC reaches the desired target
      - conditions:
          - condition: trigger
            id: soc_reached
          - condition: template
            value_template: >
              {% set prev = trigger.from_state.state | int(0) %}
              {% set new = trigger.to_state.state | int(0) %}
              {{ new == desired_soc | int(0) and prev == (desired_soc | int(0) - 1) }}
        sequence:
          - action: "{{ email_action }}"
            data:
              title: Battery SOC reached {{ desired_soc }}%
              message: "{{ email_message }}"
              target: "{{ email_target }}"

    # DEFAULT CASE: If desired SOC < current SOC β†’ no charging required
    default:
      - condition: template
        value_template: "{{ desired_soc < current_soc }}"
      - action: "{{ email_action }}"
        data:
          title: Desired SOC less than current SOC
          message: "{{ email_message }}"
          target: "{{ email_target }}"

# ───────────────────────────────────────
# MODE - Prevent overlapping runs
# ───────────────────────────────────────
mode: single

1 Like