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.

A picture perfect day for the adaptive battery charging automation, finishing within minutes of the target time. Current was allocated perfectly each hour. When we get approved to export excess power, this will maximize output of the system by allocating power between load, battery charging, and export which does not exceed the capacity of the utility meter and wire.

My latest enhancements is looking at how much variance there is in solar radiation (as a proxy for solar production, which is curtailed because I can’t currently export power) and giving a boost to max charge current so that I’m not always just reacting to to not getting as much energy into the batteries as I expected. The algorithm works without this, but with it there is a more constant allocation of power. Worked to perfection today.

1 Like

Hi David,

You have done an impressive job! I´m interesting in doing something similar considering also electricity price (in Spain PVPC). Would you share you final setup / yaml files? I think your code, and your experience tuning the system is a great value.

Thank you

The code has evolved a bit from what I posted before. Here is part 1 of 2 the current package file.

#=====================================================================================
# ABC - Adaptive Battery Charging package file
#=====================================================================================

#-------------------------------------------------------------------------------------
# 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
#
# Hourly Forecast        - The Solcast hourly forecast is what drives this
# Available Solar Energy - How much of the forecast power will be "available"
# Target Hour Calc       - Run by an automation once an hour to set target hour
# Allocation This Hour   - The amount of energy we want to add this hour
# Max Charge Current     - Calculate Max Charge Current for the automation
#-------------------------------------------------------------------------------------

#-------------------------------------------------------------------------------------
# To minimize errors that can occur immediately afer a "Quick reload" put the lowest
# level sensors with the fewest dependencies first, then mid_level, and the sensors
# with the most dependencies last.
#-------------------------------------------------------------------------------------
template:

  - sensor:
    
    #---------------------------------------------------------------------------------
    # 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 capacity in kWh according to BMS 'stored_energy'"
      state: >-
        {{ states('input_number.abc_battery_capacity') }}

    - 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') }}

      # 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'll start a gradual taper down to 20A at 99% 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 → Inverter Maximum Charging Current (.5c for 2 batteries)
      #  85 % → SOC where tapering starts
      #  40 A → the lowest allowed "maximum" current
      #  180 = 220 - 40 → the full taper span
      #  12 = 97 - 85 → the SOC range over which tapering occurs
      #  15A steps: 180 / 12

    - 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 (input number adjusted based on SOC)"
      state: >-
        {% set soc = states('sensor.deye_battery') | float(-1) %}
        {% set user_max = states('input_number.abc_max_chg_rate') | float(-1) %}

        {% if soc < 85 %}
          {{ user_max }}
        {% else %}
          {% set value = 220 - ((soc - 85) / 12 * 180) %}
          {{ [ [value, 25] | 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 (input_number)"
      state: >-
        {{ states('input_number.abc_min_chg_rate') | 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% (input_number set by an automation)"
      state: >-
        {{ states('input_number.abc_target_hour') | 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 maximum allowed target hour (input_number)"
      state: >-
        {{ states('input_number.abc_max_target_hour') | int }}

    - name: "ABC Safety Factor"
      unique_id: abc_safety_factor
      unit_of_measurement: "%"
      state_class: measurement
      icon: mdi:shield-check
      attributes:
        comment: "Percent of 'available' energy to keep in reserve and not available for allocation"
      state: >-
        {{ states('input_number.abc_safety_factor') | int }}

    - name: "ABC Solar Variability Scaling"
      unique_id: abc_solar_variability_scaling
      unit_of_measurement: "x"
      state_class: measurement
      icon: mdi:chart-bell-curve
      attributes:
        comment: "Controls how aggressive we are about scaling max charge current for solar variability"
      state: >-
        {{ states('input_number.abc_solar_variability_scaling') }}

      # I could use actual charging voltage but because of the flat voltage curve
      # it would only vary by less than 1% so a ballpark number will suffice.
      # Voltage is a fudge factor. Current can be tweaked by changing voltage
    - name: "ABC Charge Voltage"
      unique_id: abc_charge_voltage
      unit_of_measurement: "V"
      device_class: voltage
      state_class: measurement
      icon: mdi:alpha-v-circle-outline
      attributes:
        comment: "Battery Charging Voltage to use to convert energy to current"
      state: 53.7

    #-------------------------------------------------------------------------------------------------
    # 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.
    #
    # state is just a single number - the total amount of solar energy forecast for the rest of the day.
    # attributes contains the hourly forecast from the current hour until the last hour of meaningful production.
    #-------------------------------------------------------------------------------------------------

    - name: "ABC Hourly Forecast"
      unique_id: abc_hourly_forecast
      device_class: energy
      unit_of_measurement: "kWh"
      state_class: total
      icon: mdi:solar-panel
      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') | float(-1) %}
        {% 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') | float(-1) %}
          {% 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 est10 = f.pv_estimate10 | float(-1) | round(1) %}
            {% set est50 = f.pv_estimate | float(-1) | round(1) %}
            {% set est90 = f.pv_estimate90 | float(-1) | 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 s = states('sensor.abc_home_loads') %}
            {% set home_loads = (s if s.startswith('{') else '{}') | from_json %}
            {% set hour = f.period_start.strftime('%H') %}
            {% set home_load = home_loads.get(hour, 0) | float(-1) | 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 }}",
              "pv_estimate90": "{{ est90 }}"
            }{% if not loop.last %},{% endif %}
          {% endfor %}
          ]

    #-------------------------------------------------------------------------------------------------
    # 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
      icon: mdi:solar-panel
      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') | int(-1) %}
        {% set current_hour = now().hour %}
        {% set h = states('sensor.abc_home_loads') %}
        {% set home_loads = (h if h.startswith('{') else '{}') | 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(-1) %}
            {% set home_load = home_loads.get(hour, 0) | float(-1) %}
            {% set net_value = [value - home_load, 0] | max %}
            {% set ns.total = ns.total + net_value %}
          {% endif %}
        {% endfor %}
        
        {{ ns.total | round(1) }}

    #----------------------------------------------------------------------------------------------
    # Version of available_solar_energy for use by the target hour calculation, which needs 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
      icon: mdi:solar-panel
      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 s = states('sensor.abc_home_loads') %}
        {% set home_loads = (s if s.startswith('{') else '{}') | 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 *** Most other sensors are "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
      icon: mdi:lightning-bolt
      availability: >-
        {{ has_value('sensor.abc_hourly_forecast') and
           has_value('sensor.abc_home_loads') }}
      attributes:
        comment: "Calculates energy remaining to add to the battery during the current hour"
        energy_needed: "{{ states('sensor.abc_energy_needed') }}"
        energy_available: "{{ states('sensor.abc_available_solar_energy') }}"
      state: >-
        {% set current_hour = now().hour %}
        {% set target_hour = states('input_number.abc_target_hour') | float(-1) %}
        {% set energy_needed = states('sensor.abc_energy_needed') | float(-1) %}
        {% set energy_available = states('sensor.abc_available_solar_energy') | float(-1) %}

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

        {% if (8 <= 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 h = states('sensor.abc_home_loads') %}
          {% set home_loads = (h if h.startswith('{') else '{}') | 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 %}

          {# 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 %}
          {% set adjustment = current_hour_forecast_net * hour_fraction_past %}
          {% set adjusted_current_hour = current_hour_forecast_net - adjustment %}
          {% set adjusted_energy_available = energy_available - adjustment %}

          {# Calculate proportion based on adjusted values and clamp between 0 and 1 #}
          {% set energy_proportion = adjusted_current_hour / (adjusted_energy_available + 0.01) %}
          {% set energy_proportion = [0, [ 1, energy_proportion ] | min ] | max %}

          {# Calculate how much energy to allocate to the remainder of the current hour #}
          {% set allocation = energy_needed * energy_proportion %}
        {% else %}
          {% set allocation = energy_needed %}
        {% endif %}

        {{ allocation | round(2) }}

        {% set debug=0 %}{%- if debug %}Remaining Allocation This Hour:
        Battery Capacity: {{ battery_capacity }}
        - Stored Energy: {{ stored_energy }}
        = Energy Needed: {{ energy_needed }}
        Energy Available: {{ energy_available }}
        {% if energy_needed < energy_available %}
        Minutes Remaining: {{ minutes_remaining }}
        Current Hour Forecast: {{ current_hour_forecast | round(2) }} kWh
        Home Load: {{ home_load }} kWh
        Current Hour Forecast Net: {{ current_hour_forecast_net }} kWh
        Hour Fraction Past: {{ hour_fraction_past | round(2) }}
        Adjustment: {{ adjustment | round(2) }}
        Adjusted Current Hour: {{ adjusted_current_hour | round(2) }} kWh
        Adjusted Energy Available: {{ adjusted_energy_available | round(2) }} kWh
        Energy Proportion: {{ energy_proportion | round(2) }}
        Allocation (Calc): {{ allocation }} kWh
        {% endif %}{% endif %}        
 
    #--------------------------------------------------------------------------------
    # Calculate how variable Solar Radiation (a proxy for PV power) has been over the
    # previous 10 minutes. A ratio of 2.0 means solar radiation max is 2 times the min.
    #--------------------------------------------------------------------------------

    - name: "ABC Solar Variability Ratio"
      unique_id: abc_solar_variability_ratio
      unit_of_measurement: "x"
      state: >
        {% set max = states('sensor.abc_solar_radiation_max') | float(0) %}
        {% set min = states('sensor.abc_solar_radiation_min') | float(0) %}
        {% set avg = states('sensor.abc_solar_radiation_avg') | float(0) %}
        {% if avg > 100 %}
          {{ ((max - min) / avg + 1) | round(2) }}
        {% else %}
          1
        {% endif %}
      attributes:
        comment: "Solar Variability Ratio - a measure of how much variation there is in solar radiation"
        max: "{{ states('sensor.abc_solar_radiation_max') }}"
        min: "{{ states('sensor.abc_solar_radiation_min') }}"
        avg: "{{ states('sensor.abc_solar_radiation_avg') }}"

    #--------------------------------------------------------------------------------
    # Scale the variability ratio by an input_number for controlling how aggressive
    # we want to be about scaling max charge voltage. 0 means make no adjustment for
    # variability. This is how it works now. 1 means scale linearly with variability,
    # so 2x variability means a 2x adjustment to max charge voltage. Set a baseline
    # and clamp at 2x
    #--------------------------------------------------------------------------------

    - name: "ABC Solar Variability Boost"
      unique_id: abc_solar_variability_boost
      unit_of_measurement: "x"
      state: >
        {% set ratio = states('sensor.abc_solar_variability_ratio') | float(1) %}
        {% set baseline = 1.25 %}
        {% set scale = states('input_number.abc_solar_variability_scaling') | float(0) %}
        {% set adjusted = 1 + max(ratio - baseline, 0) %}
        {{ (1 + (adjusted - 1) * scale) | round(2) }}
      attributes:
        comment: "Multiplier based on solar variability"
        variability_ratio: "{{ states('sensor.abc_solar_variability_ratio') }}"
        scaling_factor: "{{ states('input_number.abc_solar_variability_scaling') }}"

    #-------------------------------------------------------------------------------------------------
    # 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') }}
      state: >-
        {% set allocation = states('sensor.abc_allocation_this_hour') | float(0) %}
        {% set target_hour = states('input_number.abc_target_hour') | float(-1) %}
        {# If past target_hour, try to finish charging in the next 15 minutes #}
        {% set time_fraction = (0.25 if now().hour >= target_hour else (60 - now().minute) / 60) | round(2) %}
        {% set charge_voltage = states('sensor.abc_charge_voltage') | float(-1) %}
        {% set min_charge_current_limit = states('input_number.abc_min_chg_rate') | int(-1) %}
        {% set max_charge_current_limit = states('sensor.abc_max_chg_rate') | int(-1) %}
        {% set max_charge_current_now = states('number.deye_battery_max_charging_current') | int(-1) %}
        {% set boost = states('sensor.abc_solar_variability_boost') | float(1.0) %}

        {% set charge_current_raw = allocation * 1000 / time_fraction / charge_voltage %}
        {% set charge_current_boosted = (charge_current_raw * boost) | round(0) %}
        {% set charge_current = max(min_charge_current_limit, charge_current_boosted) %}
        {% set charge_current = min(max_charge_current_limit, charge_current) %}

        {{ charge_current }}

        {% set debug=0 %}{%- if debug %}Max Charge Current Calc:
        Allocation This Hour: {{ allocation }} kWh
        Time Fraction: {{ time_fraction }} h
        Charge Current Raw: {{ charge_current_raw | round(0) }} A
        Solar Variability Boost: {{ states('sensor.abc_solar_variability_boost') }}
        Charge Current boost: {{ charge_current_boosted }} A
        Min Charge Current (Limit): {{ min_charge_current_limit }} A
        Max Charge Current (Limit): {{ max_charge_current_limit }} A
        Max Charge Current (Now): {{ max_charge_current_now }} A
        Max Charge Current (Next): {{ charge_current }} A
        {% endif %}

      attributes:
        comment: "This number is used in the automation to set sensor.deye_max_charging_current"
        energy_allocation_remaining_this_hour: >-
          {{ states('sensor.abc_allocation_this_hour') }}
        time_fraction: >-
          {{ ((60 - now().minute) / 60) | round(2) }}
        Solar Variability Boost: >-
          {{ states('sensor.abc_solar_variability_boost') }}
        charge_current_raw: >-
          {% set allocation = states('sensor.abc_allocation_this_hour') | float(0) %}
          {% set time_fraction = ((60 - now().minute) / 60) | round(3) %}
          {% set charge_voltage = states('sensor.abc_charge_voltage') | float(52.5) %}
          {{ (allocation * 1000 / time_fraction / charge_voltage) | round(0) }}
 
    #-------------------------------------------------------------------------------------------------
    # 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 100% SOC can be delayed until 3: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') }}
      state: >-
        {% set energy_needed = states('sensor.abc_energy_needed') | float(-1) %}
        {% set energy_available = states('sensor.abc_total_available_solar_energy') | float(-1) %}
        {% set safety_factor = states('input_number.abc_safety_factor') | float(-1) %}
        {% set max_target_hour = states('input_number.abc_max_target_hour') | float(-1) %}
        {% set safe_available = states('sensor.abc_total_safe_available') | float(-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 s = states('sensor.abc_home_loads') %}
          {% set home_loads = (s if s.startswith('{') else '{}') | 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 }} kWh
            Est Load: {{ home_load }} kWh
            Net Avail: {{ net_value }} kWh
            Total Avail: {{ ns.accumulated | round(1) }} kWh
            Goal: {{ safe_available }} kWh
            {% 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 %}

      attributes:
        comment: "Calculate hour to reach 100% SOC with safety factor for forecast uncertainty"
        energy_needed: "{{ states('sensor.abc_energy_needed') }}"
        energy_available: "{{ states('sensor.abc_total_available_solar_energy') }}"
        safety_factor: "{{ states('input_number.abc_safety_factor') }}"
        safe_available: "{{ states('sensor.abc_total_safe_available') }}"
        max_target_hour: "{{ states('input_number.abc_max_target_hour') }}"

Part 2 of 2

This package file has gotten ridiculously huge. ¯_(ツ)_/¯

    #-------------------------------------------------------------------------------------------------
    # Safe Available Energy
    #-------------------------------------------------------------------------------------------------
 
    - name: "ABC Total Safe Available"
      unique_id: abc_total_safe_available
      unit_of_measurement: "kWh"
      device_class: energy
      state_class: total
      icon: mdi:lightning-bolt
      state: >-
        {% set energy_available = states('sensor.abc_total_available_solar_energy') | float(-1) %}
        {% set safety_factor = states('input_number.abc_safety_factor') | float(-1) %}
        {% set safe_available = energy_available * (1 - safety_factor/100) %}
        {{ safe_available | round(1) }}
      attributes:
        comment: "Safe Available Energy = Total Available Energy minus a Safety Factor"
        energy_available: "{{ states('sensor.abc_total_available_solar_energy') }}"
        safety_factor: "{{ states('input_number.abc_safety_factor') }}"

    #-------------------------------------------------------------------------------------------------
    # Energy Needed
    #
    # Capacity and stored_energy are both a fiction but subtracting one from the other should still
    # give an accurate number because they are both inflated by the same amount - about 2.4 kWh.
    #-------------------------------------------------------------------------------------------------

    - name: "ABC Energy Needed"
      unique_id: abc_energy_needed
      unit_of_measurement: "kWh"
      device_class: energy
      state_class: total
      icon: mdi:lightning-bolt
      state: >-
        {% set battery_capacity = states('input_number.abc_battery_capacity') | float(-1) %}
        {% set stored_energy = states('sensor.bms_stored_energy_kwh_stable') | float(-1) %}
        {{ [battery_capacity - stored_energy, 0] | max | round(1) }}
      attributes:
        comment: "The amount of energy needed to charge the batteries to 100%"
        battery_capacity: "{{ states('input_number.abc_battery_capacity') }}"
        stored_energy: "{{ states('sensor.bms_stored_energy_kwh_stable') }}"

    #---------------------------------------------------------------------------
    # Calculate SOC based on stored energy reported by the BMS. Calculating the
    # current SOC is kind of meaningless since we can get this from the BMS, but
    # to calculate what SOC will be after adding some amount of energy, we have
    # to know how the BMS calculates SOC. And here is where it gets interesting.
    # It's not, as you would assume, stored energy divided by battery capacity.
    # Nope. Why? Because Seplos lies about battery capacity and stored energy.
    # There isn't 24 kWh of energy from 0% to 100%, there is 22 kWh from 10.9%
    # to 100%, so the "slope" of the SOC curve has to be adjusted for "useable
    # capacity" and "useable SOC range".
    #---------------------------------------------------------------------------

    - name: "ABC current SOC (Calc)"
      unique_id: abc_current_soc_calc
      unit_of_measurement: "%"
      device_class: battery
      state_class: measurement
      icon: mdi:battery
      state: >-
        {% set soc_at_cutoff = 10.9 %}
        {% set energy_at_cutoff = 2.38 %}
        {% set slope = states('sensor.abc_soc_slope_calc') | float(0) %}
        {% set stored_energy = states('sensor.bms_stored_energy_kwh_stable') | float(-1) %}
        {% set soc = soc_at_cutoff + ((stored_energy - energy_at_cutoff) * slope) %}
        {{ [soc, 100] | min | round(1) }}

    - name: "ABC target SOC (Calc)"
      unique_id: abc_target_soc_calc
      unit_of_measurement: "%"
      device_class: battery
      state_class: measurement
      icon: mdi:battery
      state: >-
        {% set soc_at_cutoff = 10.9 %}
        {% set energy_at_cutoff = 2.38 %}
        {% set slope = states('sensor.abc_soc_slope_calc') | float(0) %}   {# varies slightly day to day #}
        {% set stored_energy = states('sensor.bms_stored_energy_kwh_stable') | float(-1) %}
        {% set allocation_this_hour = states('sensor.abc_allocation_this_hour') | float(-1) %}
        {% set target_energy = stored_energy + allocation_this_hour %}
        {% set soc = soc_at_cutoff + ((target_energy - energy_at_cutoff) * slope) %}
        {{ [soc, 100] | min | round(1) }}

      # Calculate how much SOC changes as a function of stored energy change.
      # The result of this calculation is 4.16% change in SOC per kWh of energy.
      # It's 89.1 / 21.42. That's the usable SOC range (10.9% to 100%) divided
      # by the usable capacity, i.e. how much energy can actually be discharged
      # from the batteries.

    - name: "ABC SOC Slope (Calc)"
      unique_id: abc_soc_slope_calc
      unit_of_measurement: "%/kWh"
      icon: mdi:chart-line
      state: >-
        {% set soc_at_cutoff = 10.9 %}
        {% set energy_at_cutoff = 2.38 %}
        {% set energy_at_max = states('input_number.abc_battery_capacity') | float(0) %}

        {% set slope = (100 - soc_at_cutoff) / (energy_at_max - energy_at_cutoff) %}
        {{ slope | round(3) }}
       
    #-------------------------------------------------------------------------------------------------
    # "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 eventually became their own
    # standalone sensors. The remaining values were so trivial that they seemed fine to leave here — just
    # quick calculations used by the dashboard.
    #
    # There's a problem with this approach: attributes don't have attributes. For any attribute that's
    # the result of a calculation, there's no way to "drill down" in the UI to see the constituant values.
    # The more-info dialog shows nothing. Worse, attribute values aren't stored in history, so there's
    # no record of how they changed over time or why.
    #
    # Even trivial calculations deserve to be template sensors if they represent 'functions'. By making
    # them standalone sensors — with attributes exposing their inputs — those inputs can be individually
    # tracked and visualized. And if all inputs are either input helpers or template sensors, their values
    # are preserved in history, enabling full introspection and time-series reconstruction.
    #-------------------------------------------------------------------------------------------------

    - 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') | float(-1) %}
          {% 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') | float(-1) %}
          {{ (60 - now().minute) if now().hour < target_hour else 0 }}

        energy_stored: "{{ states('sensor.bms_stored_energy_kwh_stable') | float(-1) }}"

        energy_needed: "{{ states('sensor.abc_energy_needed') }}"

        energy_available: "{{ states('sensor.abc_total_available_solar_energy') | float(-1) }}"

        safe_available: "{{ states('sensor.abc_total_safe_available') | float(-1) }}"

        # SOC according to the BMS
        current_soc: "{{ states('sensor.bms_soc_stable') }}"
 
        # SOC according to stored_energy
        current_soc_calc: "{{ states('sensor.abc_current_soc_calc') }}"

        # This is an "aspirational" target rather than a prediction
        target_soc_calc: "{{ states('sensor.abc_target_soc_calc') }}"

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

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

    #-----------------------------------------------------------------------------------------------
    # Put the state of all of the important template sensors in one place so it's recorded in the Logbook
    #-----------------------------------------------------------------------------------------------
    # Disabled since it's not being used and is writing a lot to 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(-1),
    #        "alloc": states('sensor.abc_allocation_this_hour') | float(-1),
    #        "maxA": states('sensor.abc_max_charge_current') | int(-1),
    #        "avail": states('sensor.abc_available_solar_energy') | float(-1)
    #      }
    #      | 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') }}"

#-----------------------------------------------------------------------------------------
# input_number sensors for various "constants" set in the adaptive battery charging template
# sensors that I often tweak. These can be modified in the Adaptive Charging dashboard.
#
# 'Initial' resets the values to an initial state after reboot. Make sure this is what you want.
#-----------------------------------------------------------------------------------------
input_number:

  # This number is used to tune how aggressively we scale max charge current for solar variability
  abc_solar_variability_scaling:
    name: ABC Solar Variability Scaling
    min: 0.0
    max: 1.0
    step: 0.1
    unit_of_measurement: 'x'
    icon: mdi:chart-bell-curve

  # This is the amount of stored energy reported by the BMS at 100% SOC. It's a lie.
  abc_battery_capacity:
    name: "ABC Battery Capacity"
    max: 24.5
    min: 22.5
    step: .1
    mode: box
    icon: mdi:battery-50

  # This isn't enabled yet but it may need to be if this number proves to vary over time from 2.38
  #abc_battery_stored_energy_at_cutoff:
  #  name: "ABC Battery Stored Energy at Cutoff"
  #  max: 2.5
  #  min: 2.3
  #  step: .1
  #  mode: box
  #  icon: mdi:battery-50

  # 0 = optimistic (all pv_estimate), 1 = pessimistic (all pv_estimate10)
  abc_pessimism_ratio:
    name: "Pessimism Ratio"
    max: 1
    min: 0
    step: .1
    icon: mdi:weather-partly-cloudy

  # I have seen peak PV power of 13.5kW!

  abc_max_chg_rate:
    name: "ABC Max Chg Rate"
    max: 220
    min: 0
    step: 20
    unit_of_measurement: "A"
    icon: mdi:current-dc
    mode: slider

  # I'm generally happy with 40A but there might be times when I want to tweak this number lower or higher

  abc_min_chg_rate:
    name: "ABC Min Chg Rate"
    max: 160
    min: 0
    step: 5
    unit_of_measurement: "A"
    icon: mdi:current-dc
    mode: slider

  # Allow for the target hour to be adjusted based on either manual input or an automation
  # that looks at the forecast.

  abc_target_hour:
    name: "ABC Target Hour"
    max: 16
    min: 13
    step: 1
    unit_of_measurement: "h"
    icon: mdi:clock-check-outline
    mode: slider

  # This is used in the target hour calculation which is used by an automation to
  # dynamically set target hour. Target hour can still be set manually.
  
  abc_max_target_hour:
    name: "ABC Max Target Hour"
    max: 16
    min: 13
    step: 1
    unit_of_measurement: "h"
    icon: mdi:clock-check-outline
    mode: slider

  # Helper that is input to a template sensor abc_target_hour to calculate a target
  # hour each day based on the availability of solar energy.
  # This is set empirically. 7% to 10% seems to be about right.

  abc_safety_factor:
    name: ABC Forecast Safety Factor
    max: 15
    min: 0
    step: 1
    unit_of_measurement: "%"
    icon: mdi:shield-check
    mode: slider

  # Because number.deye_battery_max_charging_current gets set to 200A every 15 minutes,
  # this messes up our dashboard chart, so in addiiton to setting the "real" sensor the
  # automation will also set this input_number helper so that we have a clean series for
  # the chart

  abc_battery_max_charging_current:
    name: "ABC Battery Max Charging Current"
    max: 220
    min: 0
    unit_of_measurement: "A"
    icon: mdi:current-dc
    mode: slider

#-------------------------------------------------------------------------------
# input_boolean Helpers
#-------------------------------------------------------------------------------
input_boolean:

  # For turning the adaptive battery charging automation on and off

  abc_automation_switch:
    name: "ABC Automation Switch"
    icon: mdi:toggle-switch

  # For turning detailed logging on and off for the adaptive battery charging automation

  abc_debug_switch:
    name: "ABC Debug Switch"
    icon: mdi:toggle-switch

  # For a switch on the Adaptive Battery Charging dashboard to enable and disable edits.

  abc_enable_edit:
    name: ABC Enable Edit
    icon: mdi:toggle-switch

  # For turning an automation on and off which reports "unavailable" sensors

  abc_unavailable_monitor_switch:
    name: ABC Unavailable Monitor Switch"
    icon: mdi:toggle-switch

#-------------------------------------------------------------------------------
# input_select Helpers
#-------------------------------------------------------------------------------
input_select:

#---------------------------------------------------------------------------------------------
# Timers
#---------------------------------------------------------------------------------------------
timer:

  # This is used to show that the Adaptive Battery Charging automation is running

  abc_countdown_timer:
    name: ABC Countdown Timer
    duration: "00:10:00"
    restore: true

  # This is used to show that the Forecast has been updated.

  abc_forecast_timer:
    name: ABC Forecast Timer
    duration: "00:02:00"
    restore: false

#--------------------------------------------------------------------------------------
# Platform Sensors
#--------------------------------------------------------------------------------------
sensor:
  
  #-----------------------------------------------------------------------------
  # Sensors for calculating solar variability
  #-----------------------------------------------------------------------------

  # Smoothed radiation base (filter sensor)
  # Lowpass softens the extremes but it does respond to them, unlike a simple
  # moving average which papers right over them. The purpose of this sensor is
  # to not immediately respond to brief spike or dip but to respond if the spike
  # or dip is sustained.
  
  - platform: filter
    name: "ABC Solar Radiation (Smoothed)"
    unique_id: abc_solar_radiation_smoothed
    entity_id: sensor.weather_station_solar_radiation
    filters:
      - filter: lowpass
        time_constant: 3

  # Statistics Sensors for Min/Max/Avg
  # Note: If max_age set to 5 minutes, when the sun goes behind a cloud both the
  #       min and the max change resulting in a low number for variance.
  
  - platform: statistics
    name: "ABC Solar Radiation (Min)"
    unique_id: abc_solar_radiation_min
    entity_id: sensor.abc_solar_radiation_smoothed
    state_characteristic: value_min
    max_age:
      minutes: 10

  - platform: statistics
    name: "ABC Solar Radiation (Max)"
    unique_id: abc_solar_radiation_max
    entity_id: sensor.abc_solar_radiation_smoothed
    state_characteristic: value_max
    max_age:
      minutes: 10

  - platform: statistics
    name: "ABC Solar Radiation (Avg)"
    unique_id: abc_solar_radiation_avg
    entity_id: sensor.weather_station_solar_radiation
    state_characteristic: mean
    max_age:
      minutes: 10

#--------------------------------------------------------------------------------------
# Adaptive Battery Charging Automations and Scripts
#--------------------------------------------------------------------------------------
automation:
  #---------------------------------------------------------------------------------------------
  # ABC "Controller"
  #
  # The heavy lifting is all handled in template sensors in config/.packages/abc_template_sensors.yaml
  #  sensor.abc_hourly_forecast        - Builds the hourly forecast
  #  sensor.abc_available_solar_energy - Calculates "available" solar energy (less estimated load)
  #  sensor.abc_allocation_this_hour   - How much energy we want to add during the current hour
  #  sensor.abc_current                - Converts the energy needed this hour into current (A)
  #---------------------------------------------------------------------------------------------

  - alias: "ABC - Adaptive Battery Charging Controller"
    id: adaptive_battery_charging
    mode: single
    description: "Adjust charge current so that the battery doesn't reach 100% SOC until target_hour"

    # Run every 10 minutes
    triggers:
      - trigger: time_pattern
        minutes: "/10"

    # From 8 AM to 3 PM
    conditions:
      - condition: time
        after: "7:59:00"
        before: "15:01:00"
      - condition: state
        entity_id: input_boolean.abc_automation_switch
        state: "on"

    actions:

        # Let another automation which runs on the same frequency get a head start
        # Also gives template sensors time to update at the top of the hour.
      - delay:
          seconds: 2

        #---------------------------------------------------------------------------------------
        # This timer is just a visual indicator in the dashboard that the automation is running
        #---------------------------------------------------------------------------------------
      - action: timer.start
        target:
          entity_id: timer.abc_countdown_timer

        # ---------------------------------------------------------------------------------------
        # Set the adjusted Max Charging Current
        # ---------------------------------------------------------------------------------------
      - action: number.set_value
        target:
          entity_id: number.deye_battery_max_charging_current
        data:
          value: "{{ states('sensor.abc_max_charge_current') }}"

        #---------------------------------------------------------------------------------------
        # Because the real Max_Charging_Current gets set to 200A every 15 minutes, this messes up
        # our dashboard chart, so create a clean version containing just our changes for the chart
        #---------------------------------------------------------------------------------------

      - action: input_number.set_value
        target:
          entity_id: input_number.abc_battery_max_charging_current
        data:
          value: "{{ states('number.deye_battery_max_charging_current') }}"
    
        #---------------------------------------------------------------------------------------
        # Send a home assistant notification once per hour
        #---------------------------------------------------------------------------------------

      - choose:
          - conditions:
              - condition: template
                value_template: "{{ now().minute == 0 }}"
            sequence:
              - service: notify.persistent_notification
                data:
                  title: "Max Charge Current: {{ states('number.deye_battery_max_charging_current') }}A"
                  message: >-
                    Hours Rem: {{ state_attr('sensor.abc_dashboard_helper', 'remaining_hours') }}h |
                    Current SOC: {{ state_attr('sensor.abc_dashboard_helper', 'calculated_soc') }}% |
                    Target SOC: {{ state_attr('sensor.abc_dashboard_helper', 'target_soc') }}% |
                    Available Solar: {{ states('sensor.abc_available_solar_energy') }} kWh

        #---------------------------------------------------------------------------------------
        # Debug 
        #---------------------------------------------------------------------------------------

      - choose:
          - conditions:
              - condition: state
                entity_id: input_boolean.abc_debug_switch
                state: "on"
            sequence:
              - service: notify.persistent_notification
                data:
                  title: "ABC Debug"
                  message: >-
                    Target: {{ target | int }}A |
                    Allocation This Hour: {{ states('sensor.abc_allocation_this_hour') }} |
                    Available this hour: {{ state_attr('sensor.abc_dashboard_helper', 'available_this_hour') }} |
                    Minutes Remaining: {{ state_attr('sensor.abc_dashboard_helper', 'minutes_remaining') }} |
                    Energy Needed: {{ states('sensor.abc_energy_needed') }} |
                    Safe Available: {{ states('sensor.abc_total_safe_available') }}

  #---------------------------------------------------------------------------------------------
  # Set the target hour based on the availability of energy
  # Runs after the hourly forecast update and before the adaptive battery charging automation
  # runs at the top of the hour
  #---------------------------------------------------------------------------------------------

  - alias: "ABC - Set Target Hour"
    id: adaptive_battery_charging_target_hour
    mode: single
    description: "Sets the target hour for battery charging based on solar forecast"

    triggers:
      - trigger: time_pattern
        minutes: 55

    conditions:
      - condition: time
        after: "8:50:00"
        before: "12:59:00"

    actions:
      - variables:
          new_value: "{{ states('sensor.abc_target_hour_calc') | int }}"
          current_value: "{{ states('input_number.abc_target_hour') | int }}"
    
      - choose:
          - conditions:
              - condition: template
                value_template: "{{ new_value != current_value }}"
            sequence:
              - service: input_number.set_value
                target:
                  entity_id: input_number.abc_target_hour
                data:
                  value: "{{ new_value }}"
    
              - service: notify.persistent_notification
                data:
                  title: "Target Hour set to: {{ new_value }}"
                  message: >-
                    Energy Needed: {{ states('sensor.abc_energy_needed') }} kWh,
                    Energy Available: {{ states('sensor.abc_total_available_solar_energy') }} kWh

  #--------------------------------------------------------------------------------------
  # Send a notification when any of the entities used by the adaptive battery charging
  # template sensors are "unavailable" at the moment the automation runs. The ABC automation
  # includes a 1 second delay to let this automation run first so I can learn what sensors
  # are causing errors.
  #--------------------------------------------------------------------------------------

  - alias: "ABC - Notify unavailable entities"
    id: notify_unavailable_entities
    description: "Send a notification with a list of 'unavailable' entities"

    triggers:
      - trigger: time_pattern
        minutes: "/10"

    conditions:
      - condition: state
        entity_id: input_boolean.abc_unavailable_monitor_switch
        state: "on"

      - condition: template
        value_template: "{{ unavailable_entities | length > 0 }}"

      - condition: time
        after: "7:59:00"
        before: "15:01:00"

    # The exclusions aren't needed any more but I'm leaving it in case I want to exclude some

    variables:
      exclusions:
        - sensor.sonoff_powr320d_action

      includes_explicit:
        - sensor.solcast_pv_forecast_forecast_today
        - sensor.bms_stored_energy_kwh
        - sensor.deye_battery

      unavailable_entities: >-
        {{ states
            | selectattr('state', 'equalto', 'unavailable')
            | rejectattr('entity_id', 'in', exclusions)
            | selectattr('entity_id', 'match', '^.*abc_.*|^(' ~ includes_explicit | join('|') ~ ')$')
            | map(attribute='entity_id')
            | list }}

    actions:
      - action: notify.persistent_notification
        data:
          title: "Unavailable Entities: {{ unavailable_entities | length }}"
          message: >-
            {{ unavailable_entities | join('\n') }}

  #--------------------------------------------------------------------------------------
  # Send a notification when the batteries hit 100% SOC
  # Disabled in the UI
  #--------------------------------------------------------------------------------------

  - alias: "ABC - Notify 100% SOC"
    id: battery_100_percent_soc
    mode: single
    description: "Send a notification when the batteries hit 100% SOC"

    triggers:

        # This should trigger an instant before 100% SOC
      - trigger: numeric_state
        entity_id: sensor.bms_voltage_stable
        above: 55.5

      - trigger: numeric_state
        entity_id: sensor.bms_soc_stable
        above: 99.9

    conditions: []

    actions:
      - action: notify.persistent_notification
        data:
          title: "100% SOC"
          message: >-
            Voltage: {{ states('sensor.bms_voltage') }} V,
            Current: {{ states('sensor.deye_battery_current') }}A,
            SOC: {{ states('sensor.bms_soc_stable') }} %

  #--------------------------------------------------------------------------------------
  # Initialize number.deye_battery_max_charging_current
  #        and number.deye_battery_max_discharging_current
  # to start each day at 100A. These entities can be left with random values after
  # adaptive charging and discharging automations run.
  #
  # If the grid is down when inverter settings are changed, they're rejected (WHY?)
  #--------------------------------------------------------------------------------------

  - alias: "ABC - Initialize Max Charging/Discharging Current at 7 AM"
    id: initialize_max_charging_current
    description: "'Initialize' Max Battery Charging and Discharging Current to 100A"

    # Added a second trigger in case the grid is down during the first try

    triggers:
      - trigger: time
        at:
          - "07:00:00"
          - "07:20:00"

    variables:
      max_chg: 70
      max_dischg: 100

    actions:
      - action: number.set_value
        target:
          entity_id: number.deye_battery_max_charging_current
        data:
          value: "{{ max_chg }}"

      - service: script.battery_saver_set_discharge_limit
        data:
          limit: "{{ max_dischg }}"
          reason: "Initialize Max Discharge Current"

      - action: input_number.set_value
        target:
          entity_id: input_number.abc_battery_max_charging_current
        data:
          value: "{{ max_chg }}"

  #---------------------------------------------------------------------------------------------
  # Turn Open Loop Off / On near 100%
  #
  # A BMS firmware update is putting unecessary stress on my batteries. Switch the inverter to
  # open loop mode at 98% to let the inverter handle the final push to 100% and holding the
  # batteries to 54.0v (3.375v) "float" voltage.
  #
  # Tried and abandoned. The batteries hit absorption voltage and then...nothing. SOC drops to
  # 98% while power is being exported. Charging probably restarts at some point but I didn't
  # wait to see.
  #---------------------------------------------------------------------------------------------

  - alias: "ABC - Start Open Loop"
    id: abc_start_open_loop
    description: "Switch to open loop mode when SOC hits 98%"
    mode: single

    triggers: []
    #  - trigger: numeric_state
    #    entity_id: sensor.deye_battery
    #    above: 97

    actions:
      - service: select.select_option
        target:
          entity_id: select.deye_battery_control_mode
        data:
          option: "Lead-Battery, four-stage charging method"

      - service: notify.persistent_notification
        data:
          title: "Control Mode set to Open Loop to finish charging"
          message: >-
            SOC: {{ states('sensor.deye_battery') }}%


  - alias: "ABC - Start Closed Loop"
    id: abc_start_closed_loop
    description: "Switch back to closed loop mode when SOC falls to 96%"
    mode: single

    triggers: []
    #  - trigger: numeric_state
    #    entity_id: sensor.deye_battery
    #    below: 99

    actions:
      - service: select.select_option
        target:
          entity_id: select.deye_battery_control_mode
        data:
          option: "Lithium"

      - service: notify.persistent_notification
        data:
          title: "Control Mode reset to Closed Loop"
          message: >-
            SOC: {{ states('sensor.deye_battery') }}%

#--------------------------------------------------------------------------------------
# Scripts
#--------------------------------------------------------------------------------------
script:

  #------------------------------------------------------------------------------------
  # input_number.abc_battery_max_charging_current is a "clean" version of the charging
  # current sensor for use in apexcharts so that current doesn't show a spike to 200A
  # every 15 minutes when the solar_model automaation runs. This script updates the
  # value when it's changed manually in the ABC dashboard with a HOLD action.
  #
  # How is this triggered?
  #------------------------------------------------------------------------------------

  sync_battery_max_charging_current:
    alias: "Sync abc_battery_max_charging_current to deye_battery_max_charging_current"
    description: "Update input_number.abc_battery_max_charging_current whenever number.deye_battery_max_charging_current is changed"
    mode: single
    sequence:
      - service: input_number.set_value
        data:
          entity_id: input_number.abc_battery_max_charging_current
          value: "{{ states('number.deye_battery_max_charging_current') | int }}"

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

A few things I’ve learned along the way.

  1. I start with something very close to the “10%” forecast. It’s always possible to slow down but not always possible to catch up. The forecast gets updated every hour so there’s less and less uncertainty as the day wears on, so starting near 10% simply “front loads” charging.
  2. I tried delaying 100% until late afternoon but it gets real sketchy so now I’m usually targeting 3 PM and that’s trending toward 2 PM as I’ve lowered the target voltage quite a bit so I’m no longer as concerned about holding a high voltage for a few hours, so erring on the side of getting the batteries charged is making more sense.
  3. This automation was originally about slowing down charging but the ability to speed up charging has been the difference between getting the batteries charged and not getting them charged on a few occasions. For example there’s bright sunshine until 11 and then rain. Being able to charge at maximum speed has been very useful. I’d prefer not to charge at near .5C but I will if I need to.
  4. I’m now supplementing solar with grid power if the forecast is for the batteries to not get charged. That’s a separate automation that doesn’t interfere with this one. The batteries don’t care where the power comes from. I have to do this because grid power is available during the day but the grid can be extremely fragile in the evening. If we run out of battery power before about midnight, we can suffer brownouts as the grid voltage falls to 10% below nominal.
  5. Putting info in attributes for easy inspection from the dashboard has been super helpful in figuring out why a sensor has the value that it does.
  6. Putting debug code right in state has been super helpful. Just copy state into the template tester and change debug = 1 and all of the local variables are right there for inspection.

Thank you very much !! I will try to adapt it to my particular situation. My goal is to optimice the battery load based on forecast data and electricity prices. I had a look at EMHASS but it seems hard to configure. Thanks again!

If you have any suggestions for how to make this more “presentable” I’m open to suggestions. I think a listing of what sensors are needed at the top might be useful. ChatGPT might be able to compile that list for me.