Adaptive Solar Battery Charging in Home Assistant using a Solar Forecast



I’ve built a system for adaptively charging 20 kWh of battery storage. Why? Four reasons, in the order they occurred to me:

  1. When I first got my batteries, before I had “tuned” anything, my inverter would charge them to a ridiculously high level and then just hold that voltage for like 5 hours. There are no parameters in the inverter settings to control battery charging except “how fast do you want to charge?” 80A? 120A? 220A? It bothered me so much too see my batteries held at 57.6v for multiple hours every day that I started writing template sensors and automations to delay 100% SOC until later in the day so the batteries didn’t spend so much time at a high voltage level. This is no longer a reason but this is what started it.
  2. I’m in Thailand where a 15(45)A electrical service is typical. That’s 15A continuous at 220v, 45A peak, with aluminum wire from the pole sized to match, but I have a 10kW inverter with 12.7 kW of solar panels. I’m limited on how much power I can export by the meter and the wire. I figure 25A continuous is pushing it but the system can generate 50A on a good day. So one of the design goals is to maximize utilization by balancing grid export with battery charging.
  3. Although the primary motivation was to SLOW DOWN battery charging, there are also scenarios where I want to SPEED UP charging or otherwise maximize the capture of solar energy. For example, a day with sunshine in the morning but rain starting at noon. Or maybe a mostly cloudy day with intermittent patches of sun. Without this system I would set charging to .25c (100A). With this system I set maximum current to 220A, the limit of my inverter, DC breaker, and battery cables. I have seen with my own eyes peak PV power of 13.5k, so why not?
  4. I’m drawing a blank on the 4th reason. I’ll update this if I remember.

Some details of my system:

  • Deye 10 kW hybrid inverter
  • 12.7 kW of Longi solar panels (22 x 575)
  • 21.3 kWh of Seplos “powerwall” batteries
  • East facing on a very shallow slope roof, not that it matters

The system is composed of the typical things, of course: input_number sensors, template sensors, automations, entities cards, custom button cards,and apexcharts.

Oh, how is everything plugged in to Home Assistant:

  • Solarman integration for the inverter, connected via wifi
  • BMS Battery Management integration with an ESP32 configured as a bluetooth proxy
  • Solar Assistant via MQTT although I’m not really using it since I’m connected directly to the inverter and master BMS.
  • Solcast integration for solar forecasts. Without this it wouldn’t be possible.
  • Ecowitt weather station (informational, doesn’t drive the algorithm)

What else? What do you want to know?

  • I’m keeping track of load hourly for 3 days and using a 3 day running average in combination with the forecast to forecast “available” power.
  • Allocation “follows the solar curve”. This is key to allocating power between the batteries and export.
  • I ramp down maximum current starting at 85% so that my batteries aren’t getting 140A at 98% SOC. That actually happened.
  • I’m using a compromise between the “likely” forecast and the 10% “pessimistic” forecast. That’s what the “pessimism factor” is - it controls how much my forecast is biased toward the “likely” forecast or the “pessimistic” forecast.
  • An automation runs every hour to set a target hour. If the forecast changes, this is how I speed up or slow down charging.
  • Min and max charge rates are clamped. I played around with this a lot during development but now that the system is stable, they’re just set to 40A and 220A.
  • There’s a front-loading factor to prioritize early charging. Its exponential so more weight at 11 AM than at 1 PM.
  • There is a maximum target hour. Target hour is set hourly by an automation but I want to be able to limit how late in the afternoon it can go. I’ve found through trial and error that 3:00 is kind of the point where it becomes too late to reliably get the batteries charged to 100%. That’s where the lines for solar power and consumption cross with my east-facing panels.
2 Likes

Will you be sharing your configs?

Having examples of the configuration would be nice for certain.

I’m happy to share everything I have. Is there something in particular? The entire configuration is spread across a bunch of files: template sensors, input_number helpers, some random things in configuaration.yaml, automations, maybe even scripts. It’s probably a thousand lines of YAML. Plus the dashboard.

Here are the template sensors. Constructive feedback is always welcome.

There is a mix of states( and states. syntax because I was trying to distinguish between sensors that were “unavailable” and sensors that don’t exist. I’ll probably switch back to states( since that seems to be what Home Assistant recommends?

template:
  - sensor:
#-------------------------------------------------------------------------------------------------
# Adaptive Battery Charging template sensors 
# These template sensors comprise the algorithm to implement adaptive battery charging.
# Max Charging Current is set by automation adaptive_battery_charging_v2
#
# Hourly Forecast        - The Solcast hourly forecast is what drives this
# Allocation This Hour   - The amount of energy we want to add this hour
# Max Charge Current     - Calculate Max Charge Current for the automation
# Target Hour Calc       - Run by an automation to set target hour
#-------------------------------------------------------------------------------------------------
# To minimize errors that can occur immediately afer a "Quick reload" put the lowest level sensors
# with the fewest depencies first, then mid_level, and the sensors with the most dependencies last.
#-------------------------------------------------------------------------------------------------

#---------------------------------------------------------------------------------------------
# Template Sensor versions of ABC input_number helpers to make them display only
#---------------------------------------------------------------------------------------------

    - name: "ABC Battery Capacity"
      unique_id: abc_battery_capacity
      unit_of_measurement: "kWh"
      device_class: energy
      state_class: total
      icon: mdi:battery-50
      attributes:
        comment: "Battery capapcity in kWh according to BMS 'stored_energy'"
      state: >-
        {{ states.input_number.abc_battery_capacity.state }}

    - name: "ABC Pessimism Ratio"
      unique_id: abc_pessimism_ratio
      state_class: measurement
      icon: mdi:weather-partly-cloudy
      attributes:
        comment: "How Pessimistic am I about the forecast? (1.0 = Very Pessimistic)"
      state: >-
        {{ states.input_number.abc_pessimism_ratio.state }}

    - name: "ABC Weighting Factor"
      unique_id: abc_weighting_factor
      state_class: measurement
      icon: mdi:numeric
      attributes:
        comment: "How much the hourly allocations should be 'front-loaded'"
      state: >-
        {{ states.input_number.abc_weighting_factor.state }}

      # Added logic to set the maximum charge rate based on how much energy the
      # batteries can actually absorb. They can take 220A (.5c) up to 85% but
      # then we need to start a gradual taper down to 40A at 98% SOC.
      # What happens if we don't do this is we zap the batteries with more current
      # than they can absorb and the excess energy just gets converted to heat and
      # then there's a lot of chaos around getting the batteries to a real 100% SOC
      #
      # Here is the algorithm:
      #  220 A → 0.5C (.5c)
      #  85 % → SOC where tapering starts
      #  40 A → the lowest allowed current
      #  180 = 220 - 40 → the full taper span
      #  12 = 97 - 85 → the SOC range over which tapering occurs
      
    - name: "ABC Max Chg Rate"
      unique_id: abc_max_chg_rate
      unit_of_measurement: "A"
      device_class: current
      icon: mdi:current-dc
      attributes:
        comment: "Maximum charging rate allowed"
      state: >-
        {% set soc = states('sensor.deye_battery') | float %}
        {% set user_max = states('input_number.abc_max_chg_rate') | float %}

        {% if soc < 85 %}
          {{ user_max }}
        {% else %}
          {% set value = 220 - ((soc - 85) / 12 * 180) %}
          {{ [ [value, 40] | max, user_max ] | min }}
        {% endif %}

    - name: "ABC Min Chg Rate"
      unique_id: abc_min_chg_rate
      unit_of_measurement: "A"
      device_class: current
      icon: mdi:current-dc
      attributes:
        comment: "Minimum charging rate allowed"
      state: >-
        {{ states.input_number.abc_min_chg_rate.state | int }}

    - name: "ABC Target Hour"
      unique_id: abc_target_hour
      unit_of_measurement: "h"
      state_class: measurement
      icon: mdi:clock-check-outline
      attributes:
        comment: "The hour we want SOC to reach 100%"
      state: >-
        {{ states.input_number.abc_target_hour.state | int }}

    - name: "ABC Max Target Hour"
      unique_id: abc_max_target_hour
      unit_of_measurement: "h"
      state_class: measurement
      icon: mdi:clock-check-outline
      attributes:
        comment: "The hour we want SOC to reach 100%"
      state: >-
        {{ states.input_number.abc_max_target_hour.state | int }}

    - name: "ABC Safety Factor"
      unique_id: abc_safety_factor
      unit_of_measurement: "%"
      state_class: measurement
      icon: mdi:shield-check
      attributes:
        comment: "Used for calculating a target hour"
      state: >-
        {{ states.input_number.abc_safety_factor.state | int }}

#-------------------------------------------------------------------------------------------------
# Calculate a weight for the current hour to "front-load" charging.
# Weighting is exponential - more early in the day and less later in the day.
#-------------------------------------------------------------------------------------------------

    - name: "ABC Weight This Hour"
      unique_id: abc_weight_this_hour
      state_class: measurement
      attributes:
        comment: "Exponential weight for the current hour"
      state: >-
        {% set target_hour = states.input_number.abc_target_hour.state | float %}
        {% set hours_left = target_hour - now().hour %}
        {% set weighting_factor = states.input_number.abc_weighting_factor.state | float(1) %}

        {% set mapping = {
          1.00: 1.000,
          1.05: 1.012,
          1.10: 1.024,
          1.15: 1.036,
          1.20: 1.047,
          1.25: 1.057,
          1.30: 1.070,
        } %}

        {% set dampened_weight = mapping[weighting_factor] %}
      
        {# Ensure weight never goes below 1 (after the target hour the exponent will be negative) #}
        {% set raw = dampened_weight ** (hours_left - 1) %}
        {% set clamped = [1.3, [1, raw] | max ] | min %}
        {{ clamped | round(2) }}
      
#-------------------------------------------------------------------------------------------------
# Build a list of hourly solar forecast. Will update when the Solcast forecast updates each hour.
# Source: sensor.solcast_pv_forecast_forecast_today
#-------------------------------------------------------------------------------------------------
#
#-------------------------------------------------------------------------------------------------
# We could use the Solcast forecast directly, but creating a separete sensor provides us with
# a "clean" forecast with only the information we need and it gives us an abc_ sensor that
# shows up in developer tools with the other abc_ sensors.
#
# Modified to contain the forecast for the entire day, not just until the target hour
# Modified to split the difference between the 10% and 50% forecasts to be only moderately pessimistic
#-------------------------------------------------------------------------------------------------

    - name: "ABC Hourly Forecast"
      unique_id: abc_hourly_forecast
      device_class: energy
      unit_of_measurement: "kWh"
      state_class: total
      availability: >-
        {{ has_value('sensor.solcast_pv_forecast_forecast_today') }}

      state: >-
        {% set now_hour = now().replace(minute=0, second=0, microsecond=0) %}
        {% set pessimism_ratio = states.input_number.abc_pessimism_ratio.state | float %}
        {% set forecasts = state_attr('sensor.solcast_pv_forecast_forecast_today', 'detailedHourly') | default([]) | list %}
        
        {% set filtered = forecasts 
            | selectattr('period_start', 'ge', now_hour)
            | selectattr('pv_estimate', 'ge', 1.0)
            | list %}

        {% if filtered %}
          {% set est50 = filtered | map(attribute='pv_estimate') | map('float') | sum %}
          {% set est10 = filtered | map(attribute='pv_estimate10') | map('float') | sum %}
          {% set total = est50 * (1 - pessimism_ratio) + est10 * pessimism_ratio %}
          {{ total | round(1) }}
        {% else %}
          0
        {% endif %}

      attributes:
        comment: "Data extracted from sensor.solcast_pv_forecast_forecast_today"
        dayname: "{{ state_attr('sensor.solcast_pv_forecast_forecast_today', 'dayname') }}"
        forecast: >-
          {% set now_hour = now().replace(minute=0, second=0, microsecond=0) %}
          {% set pessimism_ratio = states.input_number.abc_pessimism_ratio.state | float(0.5) %}
          {% set forecasts = state_attr('sensor.solcast_pv_forecast_forecast_today', 'detailedHourly') | default([]) | list %}
        
          {% set filtered = forecasts 
              | selectattr('period_start', 'ge', now_hour)
              | selectattr('pv_estimate', 'ge', 1.0)
              | list %}

          {# Create a [ JSON array ] #}        
          [
          {% for f in filtered %}
            {% set est50 = f.pv_estimate | float(4.4) | round(1) %}
            {% set est10 = f.pv_estimate10 | float(3.4) | round(1) %}
            {# My estimate is a compromise between the "most likely" forecast and the "pessimistic" forecast #}
            {% set est = (est50 * (1 - pessimism_ratio) + est10 * pessimism_ratio) | round(1) %}

            {% set home_loads = states.sensor.abc_home_loads.state | from_json %}
            {% set hour = f.period_start.strftime('%H') %}
            {% set home_load = home_loads.get(hour, 0) | float(1.4) | round(1) %}
            {% set avail = ([est - home_load, 0] | max) | round(1) %}

            {
              "period_start": "{{ (f.period_start | as_datetime).strftime('%Y-%m-%d %H:%M:%S') }}",
              "pv_estimate": "{{ est }}",
              "pv_available": "{{ avail }}",
              "pv_estimate10": "{{ est10 }}",
              "pv_estimate50": "{{ est50 }}"
            }{% if not loop.last %},{% endif %}
          {% endfor %}
          ]

#-------------------------------------------------------------------------------------------------
# Wrapper for sensor.bms_stored_energy, which is prone to being "unavailable" because bluetooth
#-------------------------------------------------------------------------------------------------

    - name: "ABC Battery Stored Energy"
      unique_id: abc_battery_stored_energy_kwh
      device_class: energy
      unit_of_measurement: "kWh"
      state_class: total
      state: >-
        {% set bms = states.sensor.bms_stored_energy_kwh.state | float(0) %}
        {% if bms > 0 %}
          {{ bms }}
        {% else %}
          {% set soc = states.sensor.deye_battery.state | float(49) %}
          {% set capacity = states.input_number.abc_battery_capacity.state | float(24.2) %}
          {{ (soc / 100 * capacity) | round(1) }}
        {% endif %}

#-------------------------------------------------------------------------------------------------
# Return a list of estimated hourly consumption. Uses hourly data captured over the previous 3 days.
#
# Note: do not add state_class or device_class. These attributes apply only to numbers.
#-------------------------------------------------------------------------------------------------

    - name: "ABC Home Loads"
      unique_id: abc_home_loads
      icon: mdi:home
      state: >-
        {% set current_hour = now().hour %}
        {% set hours = ["07", "08", "09", "10", "11", "12", "13", "14", "15", "16"] %}
        {% set loads = namespace(s=[]) %}

        {% for h in hours %}
          {% set h_int = h | int %}
          {% set forecast = states('sensor.abc_home_load_' ~ h) | float(1.4) %}

          {# Special case: for the current hour, use the higher of forecast and previous hour's actual #}
          {% if h_int == current_hour %}
            {% set prev_hour = "%02d" % (h_int - 1) %}
            {% set actual = states('input_number.abc_home_load_' ~ prev_hour ~ '_day_0') | float(1.4) %}
            {% set value = [forecast, actual] | max %}
          {% else %}
            {% set value = forecast %}
          {% endif %}

          {% set loads.s = loads.s + ['"' ~ h ~ '":' ~ value | round(1)] %}
        {% endfor %}

        {# Return is a JSON-style stringified dictionary, keyed by hour #}
        { {{ loads.s | join(', ') }} }

        {# ^ The outer braces are not part of the Jinja2 expression, they're just text. Clever. #}

#-------------------------------------------------------------------------------------------------
# Calculate total "available" solar energy as the net of the solar forecast - estimated consumption
#
# This is a special purpose sensor, used in only *ONE PLACE*, the calculation of the remaining
# allocation for the current hour. That sensor does not order through the entire forecast so it
# needs this total.
# DO NOT USE IT ANYWHERE ELSE unless you are REALLY sure it's what you want.
#-------------------------------------------------------------------------------------------------

    - name: "ABC Available Solar Energy"
      unique_id: abc_available_solar_energy
      device_class: energy
      unit_of_measurement: "kWh"
      state_class: total
      availability: >-
        {{ has_value('sensor.abc_hourly_forecast') and
           has_value('sensor.abc_home_loads') }}
      attributes:
        comment: "Available solar energy after estimated home loads subtracted"

      state: >-
        {% set target_hour = states.input_number.abc_target_hour.state | int(14) %}
        {% set current_hour = now().hour %}
        {% set home_loads = states.sensor.abc_home_loads.state | from_json %}
        {% set forecast = state_attr('sensor.abc_hourly_forecast', 'forecast') | default([]) | list %}

        {% set ns = namespace(total=0) %}
        
        {# Once we hit target hour, return the energy available for the current hour #}
        {% set target_hour = current_hour + 1 if current_hour >= target_hour else target_hour %}

        {% for f in forecast %}
          {% set hour = (f.period_start | as_datetime).strftime('%H') %}
          {% if hour | int < target_hour %}
            {% set value = f.pv_estimate | float %}
            {% set home_load = home_loads.get(hour, 0) | float(1.4) %}
            {% set net_value = [value - home_load, 0] | max %}
            {% set ns.total = ns.total + net_value %}
          {% endif %}
        {% endfor %}
        
        {{ ns.total }}

#-------------------------------------------------------------------------------------------------
# Version of available_solar_energy for use by the target hour calculation, which needs to be able
# to look at the entire day, not just the hours until target_hour.
#-------------------------------------------------------------------------------------------------

    - name: "ABC Total Available Solar Energy"
      unique_id: abc_total_available_solar_energy
      device_class: energy
      unit_of_measurement: "kWh"
      state_class: total
      availability: >-
        {{ has_value('sensor.abc_hourly_forecast') and
           has_value('sensor.abc_home_loads') }}
      attributes:
        comment: "Total available solar energy after estimated home loads subtracted"

      state: >-
        {% set home_loads = states.sensor.abc_home_loads.state | from_json %}
        {% set forecast = state_attr('sensor.abc_hourly_forecast', 'forecast') | default([]) | list %}

        {% set ns = namespace(total=0) %}
        
        {% for f in forecast %}
          {% set hour = (f.period_start | as_datetime).strftime('%H') %}
          {% set value = f.pv_estimate | float %}
          {% set home_load = home_loads.get(hour, 0) | float %}
          {% set net_value = [value - home_load, 0] | max %}
          {% set ns.total = ns.total + net_value %}
        {% endfor %}
        
        {{ ns.total }}

#=================================================================================================
# Calculate how much energy we want to add during the remainder of the current hour
#
# Allocation counts down during the hour since it's the allocation 'remaining' this hour.
# It's aspirational, not a prediction
#
# This is the core calculation. Every other sensor is "input" to this calculation.
#=================================================================================================

    - name: "ABC Allocation This Hour"
      unique_id: abc_allocation_this_hour
      unit_of_measurement: "kWh"
      device_class: energy
      state_class: total
      availability: >-
        {{ has_value('sensor.abc_hourly_forecast') and
           has_value('sensor.abc_weight_this_hour') and
           has_value('sensor.abc_home_loads') }}
      attributes:
        comment: "Energy remaining to add to the battery during the current hour"
      state: >-
        {% set current_hour = now().hour %}
        {% set target_hour = states.input_number.abc_target_hour.state | float(14) %}
  
        {% set battery_capacity = states.input_number.abc_battery_capacity.state | float %}
        {% set stored_energy = states.sensor.abc_battery_stored_energy_kwh.state | float(9.9) %}
        {% set energy_needed = [battery_capacity - stored_energy, 0] | max %}
        {% set energy_available = states.sensor.abc_available_solar_energy.state | float(19.9) %}

        {# Allocation only makes sense prior to the final hour and when excess energy is available #}

        {% if 7 <= current_hour < target_hour and energy_needed < energy_available %}
          {% set minutes_remaining = 60 - now().minute %}
          {% set forecast = state_attr('sensor.abc_hourly_forecast', 'forecast') | default([]) | list %}

          {# Adjust the current hour forecast by the estimated home load #}
          {% set home_loads = states.sensor.abc_home_loads.state | from_json %}
          {% set home_load = home_loads.get(now().strftime('%H'), 0) | float %}
          {% set current_hour_forecast = forecast[0].pv_estimate | float %}
          {% set current_hour_forecast_net = (current_hour_forecast - home_load) | round(1) %}

          {# Energy needed declines while the proportion remains the same, resulting in a declining #}
          {# stairstep pattern each hour. Subtract out energy for the portion of the hour that has  #}
          {# passed, from both the numerator and denomenator, to calculate an adjusted proportion   #}

          {% set hour_fraction_past = ((60 - minutes_remaining) / 60) | round(2) %}
          {% set adjustment = (current_hour_forecast_net * hour_fraction_past) | round(2) %}
          {% set adjusted_current_hour = current_hour_forecast_net - adjustment %}
          {% set adjusted_energy_available = (energy_available - adjustment) | round(1) %}

          {# Now apply weighting to the current hour #}
          {% set weight_this_hour = states.sensor.abc_weight_this_hour.state | float(1) %}
          {% set adjusted_current_weighted = (adjusted_current_hour * weight_this_hour) | round(1) %}

          {# Calculate proportion based on adjusted values #}
          {% set energy_proportion = (adjusted_current_weighted / (adjusted_energy_available + 0.01)) | round(2) %}

          {# Calculate how much energy to allocate to the current hour #}
          {% set allocation = (energy_needed * energy_proportion) | round(1) %}

          {# Round allocation to .2 to cut the number of history updates in half #}
          {% set allocation =  (allocation / 0.2) | round(0) * 0.2 %}
        {% else %}
          {% set allocation = energy_needed %}
        {% endif %}

        {{ allocation }}

        {% set debug=0 %}{% if debug %} Remaining Allocation This Hour:
        Minutes Remaining: {{ minutes_remaining }}
        Current Hour Forecast: {{ current_hour_forecast }}
        Home Load: {{ home_load }}
        Current Hour Forecast Net: {{ current_hour_forecast_net }}
        Hour Fraction Past: {{ hour_fraction_past }}
        Adjustment: {{ adjustment }}
        Adjusted Current Hour: {{ adjusted_current_hour }}
        Adjusted Energy Available: {{ adjusted_energy_available }}
        Weight this Hour: {{ weight_this_hour }}
        Adjusted Current Weighted: {{ adjusted_current_weighted }}
        Energy Proportion: {{ energy_proportion }}
        Allocation: {{ allocation }}
        {% endif %}
        
#-------------------------------------------------------------------------------------------------
# Calculate the charge current required to deliver the allocation_this_hour amount of energy
# in the time remaining in the hour.
# This sensor is what is used in the automation to set sensor.deye_max_charging_current
#
# input_number.abc_max_charge_rate is now wrapped in a template sensor to ramp down charge rate
# above 90% SOC.
#-------------------------------------------------------------------------------------------------

    - name: "ABC Max Charge Current"
      unique_id: abc_max_charge_current
      unit_of_measurement: "A"
      device_class: current
      state_class: measurement
      availability: >-
        {{ has_value('sensor.abc_allocation_this_hour') }}
      attributes:
        comment: "This number is used in the automation to set sensor.deye_max_charging_current"
      state: >-
        {% set allocation = states.sensor.abc_allocation_this_hour.state | float %}
        {% set time_fraction = ((60 - now().minute) / 60) | round(2) %}
        {% set battery_voltage = states.sensor.deye_battery_voltage.state | float %}
        {% set min_charge_current = states.input_number.abc_min_chg_rate.state | float %}
        {% set max_charge_current = states.sensor.abc_max_chg_rate.state | float %}

        {# Round charge current to 5A to reduce the frequency of history update and clamp between min and max #}
        {% set charge_current_raw = (allocation * 1000 / time_fraction / battery_voltage) | round(0) %}
        {% set charge_current = ((charge_current_raw + 2.5) // 5) * 5 %}
        {% set charge_current = min(max_charge_current, charge_current) %}
        {% set charge_current = max(min_charge_current, charge_current) %}

        {{ charge_current }}

        {% set debug=0 %} {% if debug %} Max Charge Current Calc:
        Allocation: {{ allocation }}
        Time Fraction: {{ time_fraction }}
        Battery Voltage: {{ battery_voltage }}
        Charge Current Raw: {{ charge_current_raw }}
        Charge Current: {{ charge_current }}
        {% endif %}

#-------------------------------------------------------------------------------------------------
# Pick an appropriate target hour based on the solar forecast
#
# The idea here is to find the last hour where there is enough energy to finish charging the batteries.
# On sunny summer days, maybe 100% SOC can be delayed until 4:00. In the winter maybe it's 2:00.
# If the forecast is poor, set the target to 1:00 to make sure we aren't limiting our charge rate.
# I'm setting the max target hour to 4 PM. After 4 it's just too late in the day for east facing
# solar panels.
#
# Run by an automation each hour since the forecast can change.
#
# A note about {%- and {#-  The dashes suppress newlines in the output. The reason this is
# important is that when this code is pasted into the template sensor, it's easier to read!
#-------------------------------------------------------------------------------------------------

    - name: "ABC Target Hour Calc"
      unique_id: abc_target_hour_calc
      icon: mdi:clock-outline
      state_class: measurement
      availability: >-
        {{ has_value('sensor.abc_hourly_forecast') }}
      attributes:
        comment: "Calculate hour to reach 100% SOC with safety factor for forecast uncertainty"
      state: >-
        {% set battery_capacity = states.input_number.abc_battery_capacity.state | float(24.4) %}
        {% set stored_energy = states.sensor.abc_battery_stored_energy_kwh.state | float(9.9) %}
        {% set energy_needed = (battery_capacity - stored_energy) | round(1) %}
        {% set energy_available = states.sensor.abc_total_available_solar_energy.state | float(9.9) %}
        {% set safety_factor = states.input_number.abc_safety_factor.state | float(9) %}
        {% set max_target_hour = states.input_number.abc_max_target_hour.state | float(14) %}
        
        {# Calculate safe available energy accounting for forecast uncertainty #}
        {# Once we hit safe_available, we're into our safety margin so stop    #}
        {% set safe_available = (energy_available * (1 - safety_factor/100)) | round(1) %}

        {% set debug=0 %} {% if debug %} Target Hour Calc:
        Energy Needed:  {{ energy_needed }} kWh
        Energy Avail:   {{ energy_available }} kWh
        Safety Factor:  {{ safety_factor | int }}%
        Safe Available: {{ safe_available }} kWh  
        Excess Available: {{ safe_available > energy_needed }}
        {%- endif %}
        
        {%- if energy_needed > safe_available %}
          13
        {%- else %}
          {#- Find the last hour where it's "safe" to charge to 100% and that's our target hour #}
          {%- set home_loads = states.sensor.abc_home_loads.state | from_json %}
          {%- set forecast = state_attr('sensor.abc_hourly_forecast', 'forecast') | default([]) | list %}
          {%- set ns = namespace(accumulated=0, target_hour=18) %}
          
          {%- for f in forecast %}
            {%- set hour = (f.period_start | as_datetime).strftime('%H') %}
            {%- set value = f.pv_estimate | float %}
            {%- set home_load = home_loads.get(hour, 0) | float %}
            {%- set net_value = (value - home_load) | round(1) %}
            {%- set ns.accumulated = ns.accumulated + net_value %}

            {%- if debug %}
            Hour: {{ hour|int }}
            Forecast: {{ value }}
            Est Load: {{ home_load }}
            Net Avail: {{ net_value }}
            Total Avail: {{ ns.accumulated | round(1) }}
            Goal: {{ safe_available }}
            {% endif %}

            {%- if ns.accumulated >= safe_available and ns.target_hour == 18 %}
              {%- set ns.target_hour = hour|int + 1 %}
            {%- endif %}
          {%- endfor %}

          {{ [ns.target_hour, max_target_hour] | min }}
        {% endif %}

#-------------------------------------------------------------------------------------------------
# "Dashboard Helper"
#
# A collection of "sensors" for the dashboard. This started as a sort of "subroutine" to factor out
# calculations used by multiple template sensors but the significant ones got moved into separate
# template sensors and the ones that were left were so trivial that they could just be calculated
# where they are needed. So now it's just used by the dashboard.
#-------------------------------------------------------------------------------------------------
 
    - name: "ABC Dashboard Helper"
      unique_id: abc_dashboard_helper
      availability: >-
        {{ has_value('sensor.abc_hourly_forecast') and
           has_value('sensor.abc_allocation_this_hour') }}
      state: "OK"
      attributes:
        comment: "A collection of variables used in the ABC dashboard"

        current_time: "{{ (now().hour + (now().minute / 60)) | round(1) }}"

        remaining_hours: >-
          {% set target_hour = states.input_number.abc_target_hour.state | float(14) %}
          {% set current_time = (now().hour + (now().minute / 60)) | round(1) %}
          {{ (target_hour - current_time) | round(1) if current_time < target_hour else 0 }}

        minutes_remaining: >-
          {% set target_hour = states.input_number.abc_target_hour.state | float(14) %}
          {{ (60 - now().minute) if now().hour < target_hour else 0 }}

        energy_stored: "{{ states.sensor.abc_battery_stored_energy_kwh.state | float(9.9) }}"

        energy_needed: >-
          {% set battery_capacity = states.input_number.abc_battery_capacity.state | float %}
          {% set stored_energy = states.sensor.abc_battery_stored_energy_kwh.state | float(9.9) %}
          {{ (battery_capacity - stored_energy) | round(1) }}

        energy_available: "{{ states.sensor.abc_total_available_solar_energy.state | float(14.9) }}"

        safe_available: >-
          {% set energy_available = states.sensor.abc_total_available_solar_energy.state | float(14.9) %}
          {% set safety_factor = states.input_number.abc_safety_factor.state | float(9) %}
          {{ (energy_available * (1 - safety_factor/100)) | round(1) }}

        # SOC according to the inverter
        current_soc: "{{ states.sensor.deye_battery.state | float(49.9) }}"
 
        # This calculation gives a different number than SOC as reported by the inverter or,
        # strangely, the BMS. It's here to create a consistant reference to target_soc
        calculated_soc: >-
          {% set battery_capacity_kwh = states.input_number.abc_battery_capacity.state | float %}
          {% set energy_stored = states.sensor.abc_battery_stored_energy_kwh.state | float(9.9) %}
          {% set current_soc = min(energy_stored / battery_capacity_kwh * 100, 100) %}
          {{ current_soc | round(1) }}

        # This is an "aspirational" target rather than a prediction
        target_soc: >-
          {% set battery_capacity_kwh = states.input_number.abc_battery_capacity.state | float %}
          {% set energy_stored = states.sensor.abc_battery_stored_energy_kwh.state | float(9.9) %}
          {% set allocation_this_hour = states.sensor.abc_allocation_this_hour.state | float(3.9) %}
          {% set cumulative_energy = energy_stored + allocation_this_hour %}
          {% set target_soc = min(cumulative_energy / battery_capacity_kwh * 100, 100) %}
          {{ target_soc | round(1) }}

        available_this_hour: >-
          {% set forecast = state_attr('sensor.abc_hourly_forecast', 'forecast') | default([]) | list %}
          {% set home_loads = states.sensor.abc_home_loads.state | from_json %}
          {% set hour = "%02d" % now().hour %}
          {% set value = forecast[0].pv_estimate | float(4.9) if forecast | length > 0 else 0 %}
          {% set home_load = home_loads.get(hour, 0) | float(1.4) %}
          {{ [value - home_load, 0] | max | round(1) }}

        available_next_hour: >-
          {% set forecast = state_attr('sensor.abc_hourly_forecast', 'forecast') | default([]) | list %}
          {% set home_loads = states.sensor.abc_home_loads.state | from_json %}
          {% set hour = "%02d" % (now().hour + 1) %}
          {% set value = forecast[1].pv_estimate | float(4.9) if forecast | length > 1 else 0 %}
          {% set home_load = home_loads.get(hour, 0) | float(1.4) %}
          {{ [value - home_load, 0] | max | round(1) }}

#-------------------------------------------------------------------------------------------------
# The End
#-------------------------------------------------------------------------------------------------

There is one more sensor. I had to remove something to get under the 32000 character limit.

Debugging Home Assistant is a challenge. This is one of the things I tried. You’ll also see some {% if debug %} code so I can just drop the state code into the template sensor and change 0 to 1 to see what is going on inside the sensor.

#-----------------------------------------------------------------------------------------------
# Put the state of all of the important template sensors in one place so it's recored in the Logbook
#-----------------------------------------------------------------------------------------------

    - name: "ABC Debug"
      unique_id: abc_debug
      icon: mdi:clipboard-text-outline
      state: >-
        {{
          {
            "hr": now().hour,
            "tgt_hr": states('sensor.abc_target_hour_calc') | int(0),
            "alloc": states('sensor.abc_allocation_this_hour') | float(0),
            "maxA": states('sensor.abc_max_charge_current') | int(0),
            "avail": states('sensor.abc_available_solar_energy') | float(0)
          }
          | tojson
        }}
      attributes:
        target_hour_calc: "{{ states('sensor.abc_target_hour_calc') }}"
        hourly_forecast: "{{ states('sensor.abc_hourly_forecast') }}"
        available_solar_energy: "{{ states('sensor.abc_available_solar_energy') }}"
        allocation_this_hour: "{{ states('sensor.abc_allocation_this_hour') }}"
        max_charge_current: "{{ states('sensor.abc_max_charge_current') }}"


Yes, that’s recommended.

I can highly recommend structuring your code as packages. It makes sharing and maintenance a lot easier, especially for larger setups.

I’m doing some optimisations too, so I will compare notes, but generally I asked since it’s just useful for others to see your actual implementation (in terms of sharing a project).

You can write to the logs from scripts and automations. If you pollute your states with other output, you may get unexpected results and anomalies that need cleaning up. You can also override a state under the dev tools for the purpose of testing.

Thanks for sharing!

With regard to packages, I have been led astray by ChatGPT, which only ever mentioned putting different entity types in different files or directories. I have a hard time remembering where everything is. Having everything for a system in one file would be great, especially having all of the sensors in one file instead of 4 or 5 files. I’d probably keep automations separate but they could still be in packages/ instead of automations/ This would be a huge improvement over the way things are organized now, by entity type.

I’m not writing other output to state, unless I forget to set debug=0.

What do you mean “You can also override a state”? I plug the state code into the template sensor and add things like

Voltage: {{ voltage }}
Current: {{ current }}

I’m definitely open to better ways to help debug template sensors.

You can set a state here:

Unfortunately, most GenAIs still struggle with YAML and in particular HA YAML configuration. Best to work through the official docs.

How does setting state help with understanding what’s going in inside a template sensor?

ChatGPT has written 98% of my system, which now spans over 6000 lines not counting automations or dashboards. I tell it what I want and it generates the code. Where it struggles is with dashboard cards. Every card has different options and different syntax so that has been pure trial and error.

i reorganized my code into packages (I was using packages only for template sensors since I thought that’s all that was supported) and eliminated nearly all of the entity-specific files except for automations, which I’m leaving as a separate directory of files. There’s still some stragglers like utility_meters.

My original comment was a general one (not specifically about template sensors), related to your way of debugging. By overriding a state, a trigger-based template sensor or automation’s triggers can be tested. It’s a nice feature often missed by other users.

Gotcha. I did not know that state could be set. Good to know.

1 Like

Everything has been moved into package files now, including the automations. Packages.are the bomb. Everything in one place. I love it.

The latest iteration of the dashboard. State values in the header of an spexchart don’t update as frequently as I like so I moved them into custom buttons cards.

This automation has been running hands-off for awhile now. There were weeks where it never went a day without something changing, but it’s finally working well. It’s adjusting nicely to changes in the forecast. I’ve seen quite a few marginal days lately and it has handled those nicely as well. It’s cool to see the batteries able to take in all 140A of a break in the clouds for 20 minutes, where without the automation I would limit the charge rate to 80A.