Skip to content

Commit

Permalink
Max charge rate temperature curve adjustments (#1869)
Browse files Browse the repository at this point in the history
* Max charge rate temperature curve adjustments

* Missing file

* [pre-commit.ci lite] apply automatic fixes

* Log battery temperature

* [pre-commit.ci lite] apply automatic fixes

* Charge distribution across inverters accounted for in low power

* [pre-commit.ci lite] apply automatic fixes

* Fixes for battery temperature and multi inverter low power

* [pre-commit.ci lite] apply automatic fixes

* Adding discharge temperature curve

* [pre-commit.ci lite] apply automatic fixes

* Discharge curve tests

* [pre-commit.ci lite] apply automatic fixes

* Battery charge / discharge rate prediction

* [pre-commit.ci lite] apply automatic fixes

* Update apps.yaml

* Update example_chart.yml

* [pre-commit.ci lite] apply automatic fixes

* Typo's

* [pre-commit.ci lite] apply automatic fixes

* Update apps-yaml.md

* [pre-commit.ci lite] apply automatic fixes

* Update apps-yaml.md

---------

Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
  • Loading branch information
springfall2008 and pre-commit-ci-lite[bot] authored Jan 11, 2025
1 parent f9db1d8 commit 7ec82e0
Show file tree
Hide file tree
Showing 10 changed files with 506 additions and 74 deletions.
36 changes: 36 additions & 0 deletions apps/predbat/config/apps.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,42 @@ pred_bat:
- select.givtcp_{geserial}_battery_pause_end_time_slot
- select.givtcp2_{geserial2}_battery_pause_end_time_slot

# Battery temperature sensor per inverter, used outside REST mode to get current temperature
#
#battery_temperature:
# - sensor.givtcp_battery_stack_1_bms_temperature
# - sensor.givtcp2_battery_stack_1_bms_temperature

# Battery temperature history, only one for modelling, used to predict future temperature
#
#battery_temperature_history: sensor.givtcp_battery_stack_1_bms_temperature

# Battery temperature charge adjustment curve
# Specific in C which is a multiple of the battery capacity
# e.g. 0.33 C is 33% of the battery capacity
# values unspecified will be assumed to be 1.0 hence rate is capped by max charge rate
#battery_temperature_charge_curve:
# 19: 0.33
# 18: 0.33
# 17: 0.33
# 16: 0.33
# 15: 0.33
# 14: 0.33
# 13: 0.33
# 12: 0.33
# 11: 0.33
# 10: 0.25
# 9: 0.25
# 8: 0.25
# 7: 0.25
# 6: 0.25
# 5: 0.25
# 4: 0.25
# 3: 0.25
# 2: 0.25
# 1: 0.15
# 0: 0.00

# Inverter max AC limit (one per inverter). E.g for a 3.6kw inverter set to 3600
# If you have a second inverter for PV only please add the two values together
inverter_limit:
Expand Down
74 changes: 47 additions & 27 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@
import sys
from datetime import datetime, timedelta
from config import MINUTE_WATT, PREDICT_STEP
from utils import dp2, dp3, calc_percent_limit, find_charge_rate
from utils import dp0, dp2, dp3, calc_percent_limit, find_charge_rate
from inverter import Inverter

"""
Expand Down Expand Up @@ -101,26 +101,35 @@ def execute_plan(self):
self.log("Inverter {} Charge window will be: {} - {} - current soc {} target {}".format(inverter.id, charge_start_time, charge_end_time, inverter.soc_percent, self.charge_limit_percent_best[0]))
# Are we actually charging?
if self.minutes_now >= minutes_start and self.minutes_now < minutes_end:
new_charge_rate = int(
find_charge_rate(
self.minutes_now,
inverter.soc_kw,
window,
self.charge_limit_percent_best[0] * inverter.soc_max / 100.0,
inverter.battery_rate_max_charge,
inverter.soc_max,
self.battery_charge_power_curve,
self.set_charge_low_power,
self.charge_low_power_margin,
self.battery_rate_min,
self.battery_rate_max_scaling,
self.battery_loss,
self.log,
)
* MINUTE_WATT
target_soc = self.charge_limit_percent_best[0] if self.charge_limit_best != self.reserve else self.soc_kw
inv_target_soc = self.adjust_battery_target_multi(inverter, target_soc, True, False, check=True)

new_charge_rate, new_charge_rate_real = find_charge_rate(
self.minutes_now,
inverter.soc_kw,
window,
inv_target_soc * inverter.soc_max / 100.0,
inverter.battery_rate_max_charge,
inverter.soc_max,
self.battery_charge_power_curve,
self.set_charge_low_power,
self.charge_low_power_margin,
self.battery_rate_min,
self.battery_rate_max_scaling,
self.battery_loss,
self.log,
inverter.battery_temperature,
self.battery_temperature_charge_curve,
)
new_charge_rate = int(new_charge_rate * MINUTE_WATT)
current_charge_rate = inverter.get_current_charge_rate()

self.log(
"Inverter {} Target SOC {} (this inverter {}) Battery temperature {} Select charge rate {}w (real {}w) current charge rate {}".format(
inverter.id, target_soc, inv_target_soc, inverter.battery_temperature, new_charge_rate, new_charge_rate_real * MINUTE_WATT, current_charge_rate
)
)

# Adjust charge rate if we are more than 10% out or we are going back to Max charge rate
max_rate = inverter.battery_rate_max_charge * MINUTE_WATT
if abs(new_charge_rate - current_charge_rate) > (0.1 * max_rate) or (new_charge_rate == max_rate):
Expand Down Expand Up @@ -519,7 +528,7 @@ def execute_plan(self):
self.isExporting = isExporting
return status, status_extra

def adjust_battery_target_multi(self, inverter, soc, is_charging, is_exporting, isFreezeCharge=False):
def adjust_battery_target_multi(self, inverter, soc, is_charging, is_exporting, isFreezeCharge=False, check=False):
"""
Adjust target SoC based on the current SoC of all the inverters accounting for their
charge rates and battery capacities
Expand All @@ -529,24 +538,30 @@ def adjust_battery_target_multi(self, inverter, soc, is_charging, is_exporting,

if isFreezeCharge:
new_soc_percent = soc
self.log("Inverter {} adjust target soc for hold to {}% based on requested all inverter soc {}%".format(inverter.id, new_soc_percent, soc))
if not check:
self.log("Inverter {} adjust target soc for hold to {}% based on requested all inverter soc {}%".format(inverter.id, new_soc_percent, soc))
elif soc == 100.0:
new_soc_percent = 100.0
self.log("Inverter {} adjust target soc for charge to {}% based on requested all inverter soc {}%".format(inverter.id, new_soc_percent, soc))
if not check:
self.log("Inverter {} adjust target soc for charge to {}% based on requested all inverter soc {}%".format(inverter.id, new_soc_percent, soc))
elif soc == 0.0:
new_soc_percent = 0.0
self.log("Inverter {} adjust target soc for export to {}% based on requested all inverter soc {}%".format(inverter.id, new_soc_percent, soc))
if not check:
self.log("Inverter {} adjust target soc for export to {}% based on requested all inverter soc {}%".format(inverter.id, new_soc_percent, soc))
else:
add_kwh = target_kwh - self.soc_kw
add_this = add_kwh * (inverter.battery_rate_max_charge / self.battery_rate_max_charge)
new_soc_kwh = max(min(inverter.soc_kw + add_this, inverter.soc_max), inverter.reserve)
new_soc_percent = calc_percent_limit(new_soc_kwh, inverter.soc_max)
self.log(
"Inverter {} adjust target soc for charge to {}% ({}kWh/{}kWh {}kWh) based on going from {}% -> {}% total add is {}kWh and this battery needs to add {}kWh to get to {}kWh".format(
inverter.id, soc, target_kwh, self.soc_max, inverter.soc_max, soc_percent, new_soc_percent, dp2(add_kwh), dp2(add_this), dp2(new_soc_kwh)
if not check:
self.log(
"Inverter {} adjust target soc for charge to {}% ({}kWh/{}kWh {}kWh) based on going from {}% -> {}% total add is {}kWh and this battery needs to add {}kWh to get to {}kWh".format(
inverter.id, soc, target_kwh, self.soc_max, inverter.soc_max, soc_percent, new_soc_percent, dp2(add_kwh), dp2(add_this), dp2(new_soc_kwh)
)
)
)
inverter.adjust_battery_target(new_soc_percent, is_charging, is_exporting)
if not check:
inverter.adjust_battery_target(new_soc_percent, is_charging, is_exporting)
return new_soc_percent

def reset_inverter(self):
"""
Expand Down Expand Up @@ -602,6 +617,7 @@ def fetch_inverter_data(self, create=True):
self.discharge_rate_now = 0.0
self.pv_power = 0
self.load_power = 0
self.battery_temperature = 0
found_first = False

if create:
Expand Down Expand Up @@ -666,6 +682,10 @@ def fetch_inverter_data(self, create=True):
self.pv_power += inverter.pv_power
self.load_power += inverter.load_power
self.current_charge_limit = calc_percent_limit(self.current_charge_limit_kwh, self.soc_max)
self.battery_temperature += inverter.battery_temperature

# Work out battery temperature
self.battery_temperature = int(dp0(self.battery_temperature / self.num_inverters))

# Remove extra decimals
self.soc_max = dp3(self.soc_max)
Expand Down
97 changes: 92 additions & 5 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -630,9 +630,9 @@ def minute_data(
if to_time:
timed_to = to_time - now

minutes = int(timed.seconds / 60) + int(timed.days * 60 * 24)
minutes = int(timed.total_seconds() / 60)
if to_time:
minutes_to = int(timed_to.seconds / 60) + int(timed_to.days * 60 * 24)
minutes_to = int(timed_to.total_seconds() / 60)

if minutes < newest_age:
newest_age = minutes
Expand Down Expand Up @@ -700,12 +700,37 @@ def minute_data(

# If we only have a start time then fill the gaps with the last values
if not to_key:
state = newest_state
# Fill from last sample until now
for minute in range(60 * 24 * days):
if backwards:
rindex = minute
else:
rindex = 60 * 24 * days - minute - 1

if rindex not in mdata:
mdata[rindex] = newest_state
else:
break

# Fill gaps before the first value
state = 0
for minute in range(60 * 24 * days):
if backwards:
rindex = 60 * 24 * days - minute - 1
else:
rindex = minute
if rindex in mdata:
state = mdata[rindex]
break

# Fill gaps in the middle
for minute in range(60 * 24 * days):
rindex = 60 * 24 * days - minute - 1
if backwards:
rindex = 60 * 24 * days - minute - 1
else:
rindex = minute
state = mdata.get(rindex, state)
mdata[rindex] = state
minute += 1

# Reverse data with smoothing
if clean_increment:
Expand Down Expand Up @@ -745,6 +770,8 @@ def fetch_sensor_data(self):
self.carbon_today_sofar = 0
self.import_today = {}
self.export_today = {}
self.battery_temperature_history = {}
self.battery_temperature_prediction = {}
self.pv_today = {}
self.load_minutes = {}
self.load_minutes_age = 0
Expand Down Expand Up @@ -806,6 +833,15 @@ def fetch_sensor_data(self):
else:
self.log("Warn: You have not set pv_today in apps.yaml, you will have no previous pv data")

# Battery temperature
if "battery_temperature_history" in self.args:
self.battery_temperature_history = self.minute_data_import_export(self.now_utc, "battery_temperature_history", scale=1.0, increment=False, smoothing=False)
data = []
for minute in range(0, 24 * 60, 5):
data.append({minute: self.battery_temperature_history.get(minute, 0)})
self.battery_temperature_prediction = self.predict_battery_temperature(self.battery_temperature_history, step=PREDICT_STEP)
self.log("Fetched battery temperature history data, current temperature {}".format(self.battery_temperature_history.get(0, None)))

# Car charging hold - when enabled battery is held during car charging in simulation
self.car_charging_energy = self.load_car_energy(self.now_utc)

Expand Down Expand Up @@ -1109,6 +1145,42 @@ def fetch_sensor_data(self):
else:
self.load_inday_adjustment = 1.0

def predict_battery_temperature(self, battery_temperature_history, step):
"""
Given historical battery temperature data, predict the future temperature
For now a fairly simple look back over 24 hours is used, can be improved with outdoor temperature later
"""

predicted_temp = {}
current_temp = battery_temperature_history.get(0, 20)
predict_timestamps = {}

for minute in range(0, self.forecast_minutes, step):
timestamp = self.now_utc + timedelta(minutes=minute)
timestamp_str = timestamp.strftime(TIME_FORMAT)
predicted_temp[minute] = dp2(current_temp)
predict_timestamps[timestamp_str] = dp2(current_temp)

# Look at 30 minute change 24 hours ago to predict the up/down trend
minute_previous = (24 * 60 - minute) % (24 * 60)
change = battery_temperature_history.get(minute_previous, 20) - battery_temperature_history.get(minute_previous + step, 20)
current_temp += change
current_temp = max(min(current_temp, 30), -20)

self.dashboard_item(
self.prefix + ".battery_temperature",
state=dp2(battery_temperature_history.get(0, 20)),
attributes={
"results": self.filtered_times(predict_timestamps),
"friendly_name": "Battery temperature",
"state_class": "measurement",
"unit_of_measurement": "c",
"icon": "mdi:temperature-celsius",
},
)
return predicted_temp

def rate_replicate(self, rates, rate_io={}, is_import=True, is_gas=False):
"""
We don't get enough hours of data for Octopus, so lets assume it repeats until told others
Expand Down Expand Up @@ -1768,6 +1840,21 @@ def fetch_config_options(self):
self.log("Warn: battery_discharge_power_curve is incorrectly configured - ignoring")
self.record_status("battery_discharge_power_curve is incorrectly configured - ignoring", had_errors=True)

# Temperature curve charge
self.battery_temperature_charge_curve = self.args.get("battery_temperature_charge_curve", {})
if not isinstance(self.battery_temperature_charge_curve, dict):
self.log("Data is {}".format(self.battery_temperature_charge_curve))
self.battery_temperature_charge_curve = {}
self.log("Warn: battery_temperature_charge_curve is incorrectly configured - ignoring")
self.record_status("battery_temperature_charge_curve is incorrectly configured - ignoring", had_errors=True)

# Temperature curve discharge
self.battery_temperature_discharge_curve = self.args.get("battery_temperature_discharge_curve", {})
if not isinstance(self.battery_temperature_discharge_curve, dict):
self.battery_temperature_discharge_curve = {}
self.log("Warn: battery_temperature_discharge_curve is incorrectly configured - ignoring")
self.record_status("battery_temperature_discharge_curve is incorrectly configured - ignoring", had_errors=True)

self.import_export_scaling = self.get_arg("import_export_scaling", 1.0)
self.best_soc_margin = 0.0
self.best_soc_min = self.get_arg("best_soc_min")
Expand Down
28 changes: 27 additions & 1 deletion apps/predbat/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,7 @@ def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData=
self.battery_rate_max_discharge = 0
self.battery_rate_max_charge_scaled = 0
self.battery_rate_max_discharge_scaled = 0
self.battery_temperature = 20
self.battery_power = 0
self.battery_voltage = 52.0
self.pv_power = 0
Expand Down Expand Up @@ -251,6 +252,29 @@ def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData=

# Battery size, charge and discharge rates
ivtime = None
if self.rest_data and ("Battery_Details" in self.rest_data):
average_temp = 0
battery_count = 0
battery_capacity = 0
battery_voltage = 0
for battery in self.rest_data["Battery_Details"]:
battery_details = self.rest_data["Battery_Details"][battery]
if "BMS_Temperature" in battery_details:
average_temp += float(battery_details["BMS_Temperature"])
battery_count += 1
elif "Battery_Temperature" in battery_details:
average_temp += float(battery_details["Battery_Temperature"])
battery_count += 1
else:
for item in battery_details.values():
if type(item) is dict:
if "Battery_Temperature" in item:
average_temp += float(item["Battery_Temperature"])
battery_count += 1
if battery_count > 0:
average_temp /= battery_count
self.battery_temperature = dp2(average_temp)

if self.rest_data and ("Invertor_Details" in self.rest_data):
idetails = self.rest_data["Invertor_Details"]
if "Battery_Capacity_kWh" in idetails:
Expand Down Expand Up @@ -311,6 +335,7 @@ def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData=
if "Invertor_Time" in idetails:
ivtime = idetails["Invertor_Time"]
else:
self.battery_temperature = self.base.get_arg("battery_temperature", default=20, index=self.id)
self.soc_max = self.base.get_arg("soc_max", default=10.0, index=self.id) * self.battery_scaling
self.nominal_capacity = self.soc_max

Expand Down Expand Up @@ -411,7 +436,7 @@ def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData=
# Log inverter details
if not quiet:
self.base.log(
"Inverter {} with soc_max {} kWh nominal_capacity {} kWh battery rate raw {} w charge rate {} kW discharge rate {} kW battery_rate_min {} w ac limit {} kW export limit {} kW reserve {} % current_reserve {} %".format(
"Inverter {} with soc_max {} kWh nominal_capacity {} kWh battery rate raw {} w charge rate {} kW discharge rate {} kW battery_rate_min {} w ac limit {} kW export limit {} kW reserve {} % current_reserve {} % temperature {} c".format(
self.id,
dp2(self.soc_max),
dp2(self.nominal_capacity),
Expand All @@ -423,6 +448,7 @@ def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData=
dp2(self.export_limit * 60),
self.reserve_percent,
self.reserve_percent_current,
self.battery_temperature,
)
)

Expand Down
Loading

0 comments on commit 7ec82e0

Please sign in to comment.