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
#-------------------------------------------------------------------------------------------------