Skip to content

Commit b1d3e3e

Browse files
Support battery discharge losses
Re-work the SOC calculations to be shorter/simpler Added battery discharge loss option
1 parent e5dd257 commit b1d3e3e

3 files changed

Lines changed: 82 additions & 121 deletions

File tree

apps.yaml

Lines changed: 9 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,9 @@ pred_bat:
6262
# pv_estimate: 10
6363

6464
# Battery loss accounts for energy lost charging the battery, 0.05 is 5%
65+
# Battery loss discharge accounts for energy lost discharging the battery, 0.05 is 5%
6566
battery_loss: 0.05
67+
battery_loss_discharge: 0.05
6668

6769
# Battery scaling makes the battery smaller (e.g. 0.9) or bigger than its reported
6870
# If you have an 80% DoD battery that falsely reports it's kwh then set it to 0.8 to report the real figures
@@ -90,7 +92,7 @@ pred_bat:
9092
# When car charging hold is enable loads of above theshold kwh are ignored in the simulation
9193
# Or if an incrementing energy sensor is provided actuals can be subtracted
9294
# The wallbox sensor is an example, automatically added if you have one, or comment our or replace with your own
93-
# Car charging energy scale - 1.0 if in Kwh or enter a different figure to scale the data accordingly
95+
# Car charging energy scale - 1.0 if in Kwh or enter a different figure to scale the data accordingly (2.0 would double the data)
9496
car_charging_hold: True
9597
car_charging_energy: 're:sensor.wallbox_portal_added_energy'
9698
car_charging_energy_scale: 1.0
@@ -168,14 +170,16 @@ pred_bat:
168170
# When enabled the next charge window will be automatically configured based on the incoming rates
169171
# Only works if the charging time window has been enabled and import rates are configured with the rates_import or using Octopus import
170172
# Set window minutes defines how many minutes before the charge window we should program it (do not set above 30 if you are using Agile or similar)
173+
# Will also automatically disable charging if not required and re-enable it when required.
174+
# If you turn this off later check that 'GivTCP Enable Charge Schedule' is turned back on.
171175
set_charge_window: True
172176
set_window_notify: True
173177
set_window_minutes: 30
174178

175-
# Expermental, automatic discharge for export during high rate periods enable
176-
# force_export entity above must be set correctly and you must have an export rate set correctly also
177-
# exports are in 30-minute slots
178-
# set_discharge_window: true
179+
# When enabled automatically discharge for export during high rate periods
180+
# inverter mode and discharge start/end time entities above must be set correctly and you must have an export rate set correctly
181+
# exports are in 30-minute slots, not all slots will be used
182+
set_discharge_window: False
179183
set_discharge_notify: True
180184

181185
# When enable automatically set the battery SOC charge amount a defined number of minutes before charging starts

example_chart.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ yaxis:
1414
- min: 0
1515
max: 9.54
1616
series:
17-
- entity: sensor.givtcp_sa2243g277_soc_kwh
17+
- entity: predbat.soc_kw_h0
1818
stroke_width: 1
1919
curve: smooth
2020
name: actual

predbat.py

Lines changed: 72 additions & 115 deletions
Original file line numberDiff line numberDiff line change
@@ -715,7 +715,7 @@ def run_prediction(self, charge_limit, charge_window, discharge_window, discharg
715715
# Hold based on data
716716
car_energy = 0
717717
for offset in range(0, step):
718-
car_energy += self.get_from_incrementing(self.car_charging_energy, minute_yesterday - offset) * self.get_arg('car_charging_energy_scale', 1.0)
718+
car_energy += self.get_from_incrementing(self.car_charging_energy, minute_yesterday - offset)
719719

720720
if self.debug_enable and car_energy > 0.0 and (minute % 60) == 0 and (minute < 60*48):
721721
self.log("Hour {} car charging hold with data {} load now {} metric {}".format(minute/60, car_energy, load_yesterday, metric))
@@ -739,127 +739,76 @@ def run_prediction(self, charge_limit, charge_window, discharge_window, discharg
739739
if record:
740740
load_kwh += load_yesterday
741741

742-
# Are we within the charging time window?
743-
if self.charge_enable and (charge_window_n >= 0) and soc < charge_limit[charge_window_n]:
744-
old_soc = soc
745-
soc = min(soc + (self.charge_rate * step), charge_limit[charge_window_n])
746-
747-
# Apply battery loss to computed charging energy
748-
# For now we ignore PV in this as it's probably not a major factor when mains charging is enabled
749-
if record:
750-
energy = max(0, soc - old_soc - pv_now) / self.battery_loss
751-
752-
# Must add in grid import for load
753-
energy += load_yesterday
754-
import_kwh += energy
755-
import_kwh_battery += energy
756-
if minute_absolute in self.rate_import:
757-
metric += self.rate_import[minute_absolute] * energy
758-
else:
759-
metric += self.metric_battery * energy
742+
diff = load_yesterday - pv_now
760743

761-
if self.debug_enable and (minute % 60) == 0:
762-
self.log("Hour {} battery charging target soc {}".format(minute/60, charge_limit[charge_window_n]))
744+
# Battery behaviour
745+
battery_draw = 0
746+
if self.charge_enable and (charge_window_n >= 0) and soc < charge_limit[charge_window_n]:
747+
# Charge enable
748+
battery_draw = -max(min(self.charge_rate * step, charge_limit[charge_window_n] - soc), 0)
763749
elif (discharge_window_n >= 0) and (soc > self.reserve) and discharge_enable[discharge_window_n]:
764-
# If force discharging the battery
765-
# Stop when the battery runs out also
766-
discharge_has_run = True
767-
768-
# Work out draw
750+
# Discharge enable
769751
battery_draw = self.discharge_rate * step
770-
if soc - self.reserve < battery_draw:
752+
if (soc - self.reserve) < battery_draw:
771753
battery_draw = soc - self.reserve
772-
soc -= battery_draw
773-
diff = load_yesterday - pv_now - battery_draw
774-
775-
if diff >= 0:
776-
# Importing despite full draw?
777-
energy = diff
778-
if record:
779-
import_kwh += energy
780-
import_kwh_house += energy
781-
if minute_absolute in self.rate_import:
782-
metric += self.rate_import[minute_absolute] * energy
783-
else:
784-
metric += self.metric_house * energy
785-
else:
786-
# Export
787-
energy = -diff
788-
if record:
789-
export_kwh += energy
790-
# self.log("Discharging minute {} rate {} diff {} export {}".format(minute, battery_draw, diff, energy))
791-
if minute_absolute in self.rate_export:
792-
metric -= self.rate_export[minute_absolute] * energy
793-
else:
794-
metric -= self.metric_export * energy
795-
754+
discharge_has_run = True
796755
else:
797-
diff = load_yesterday - pv_now
798-
799-
# Apply battery loss to charging from PV
800-
if diff < 0:
801-
diff *= self.battery_loss
802-
803-
# Max charge rate, export over the cap
804-
if diff < -(self.charge_rate * step):
805-
soc -= self.charge_rate * step
806-
if record:
807-
energy = -(diff + self.charge_rate * step)
808-
export_kwh += energy
809-
if minute_absolute in self.rate_export:
810-
metric -= self.rate_export[minute_absolute] * energy
811-
else:
812-
metric -= self.metric_export * energy
813-
814-
# Max discharge rate, draw from grid over the cap
815-
if diff > (self.discharge_rate * step):
816-
soc -= self.discharge_rate * step
817-
if record:
818-
energy = diff - (self.discharge_rate * step)
819-
import_kwh += energy
820-
if self.charge_enable and (charge_window_n >= 0):
821-
# If the battery is on charge anyhow then imports are kind of the same as battery charging (price wise)
822-
import_kwh_battery += energy
823-
else:
824-
# self.log("importing to minute %s amount %s kw total %s kwh total draw %s" % (minute, energy, import_kwh_house, diff))
825-
import_kwh_house += energy
826-
827-
if minute_absolute in self.rate_import:
828-
metric += self.rate_import[minute_absolute] * energy
829-
else:
830-
if self.charge_enable and (charge_window_n >= 0):
831-
metric += self.metric_battery * energy
832-
else:
833-
metric += self.metric_house * energy
756+
# ECO Mode
757+
if diff > 0:
758+
battery_draw = min(diff, self.discharge_rate * step)
834759
else:
835-
soc -= diff
836-
837-
# Flat battery, draw from grid over the cap
838-
if soc < self.reserve:
760+
battery_draw = max(diff, -self.discharge_rate * step)
761+
762+
# Clamp battery at reserve
763+
if battery_draw > 0:
764+
soc -= battery_draw / self.battery_loss_discharge
765+
if soc < self.reserve:
766+
battery_draw -= (self.reserve - soc) * self.battery_loss_discharge
767+
soc = self.reserve
768+
769+
# Clamp battery at max
770+
if battery_draw < 0:
771+
soc -= battery_draw * self.battery_loss
772+
if soc > self.soc_max:
773+
battery_draw += (soc - self.soc_max) / self.battery_loss
774+
soc = self.soc_max
775+
776+
if (self.debug_enable) and (minute % 30 == 0):
777+
self.log("Time {} battery {} load {} (load {} pv {}) grid {} soc {}".format(self.time_abs_str(minute_absolute), battery_draw, diff, load_yesterday, pv_now, diff - battery_draw, soc))
778+
779+
# Work out left over energy after battery adjustment
780+
diff -= battery_draw
781+
782+
if diff > 0:
783+
# Import
839784
if record:
840-
energy = self.reserve - soc
841-
import_kwh += energy
842-
import_kwh_house += energy
843-
if minute_absolute in self.rate_import:
844-
metric += self.rate_import[minute_absolute] * energy
785+
import_kwh += diff
786+
if self.charge_enable and (charge_window_n >= 0):
787+
# If the battery is on charge anyhow then imports are at battery charging rate
788+
import_kwh_battery += diff
845789
else:
846-
metric += self.metric_house * energy
847-
soc = self.reserve
790+
# self.log("importing to minute %s amount %s kw total %s kwh total draw %s" % (minute, energy, import_kwh_house, diff))
791+
import_kwh_house += diff
848792

849-
# Full battery, export over the cap
850-
if soc > self.soc_max:
793+
if minute_absolute in self.rate_import:
794+
metric += self.rate_import[minute_absolute] * diff
795+
else:
796+
if self.charge_enable and (charge_window_n >= 0):
797+
metric += self.metric_battery * diff
798+
else:
799+
metric += self.metric_house * diff
800+
diff = 0
801+
else:
802+
# Export
851803
if record:
852-
energy = soc - self.soc_max
804+
energy = -diff
853805
export_kwh += energy
854806
if minute_absolute in self.rate_export:
855807
metric -= self.rate_export[minute_absolute] * energy
856808
else:
857809
metric -= self.metric_export * energy
858-
soc = self.soc_max
859-
860-
if self.debug_enable and minute % 60 == 0:
861-
self.log("Hour {} load_yesterday {} pv_now {} soc {}".format(minute/60, load_yesterday, pv_now, soc))
862-
810+
diff = 0
811+
863812
predict_soc[minute] = self.dp3(soc)
864813

865814
# Only store every 10 minutes for data-set size
@@ -1098,7 +1047,9 @@ def rate_scan_export(self, rates):
10981047

10991048
# Find charging window
11001049
self.high_export_rates = self.rate_scan_window(rates, rate_low_min_window, rate_average * rate_high_threshold, True)
1050+
return rates
11011051

1052+
def publish_rates_export(self):
11021053
if self.high_export_rates:
11031054
window_n = 0
11041055
for window in self.high_export_rates:
@@ -1128,13 +1079,12 @@ def rate_scan_export(self, rates):
11281079
self.log("No high export rate period found")
11291080
self.set_state("predbat.high_rate_export_start", state='undefined', attributes = {'friendly_name' : 'Next high export rate start', 'device_class': 'timestamp', 'icon': 'mdi:table-clock'})
11301081
self.set_state("predbat.high_rate_export_end", state='undefined', attributes = {'friendly_name' : 'Next high export rate end', 'device_class': 'timestamp', 'icon': 'mdi:table-clock'})
1131-
self.set_state("predbat.high_rate_export_cost", state=rate_average, attributes = {'friendly_name' : 'Next high export rate cost', 'state_class': 'measurement', 'unit_of_measurement': 'p', 'icon': 'mdi:currency-usd'})
1082+
self.set_state("predbat.high_rate_export_cost", state=self.rate_export_average, attributes = {'friendly_name' : 'Next high export rate cost', 'state_class': 'measurement', 'unit_of_measurement': 'p', 'icon': 'mdi:currency-usd'})
11321083
if len(self.high_export_rates) < 2 and not SIMULATE:
11331084
self.set_state("predbat.high_rate_export_start_2", state='undefined', attributes = {'friendly_name' : 'Next+1 high export rate start', 'device_class': 'timestamp', 'icon': 'mdi:table-clock'})
11341085
self.set_state("predbat.high_rate_export_end_2", state='undefined', attributes = {'friendly_name' : 'Next+1 high export rate end', 'device_class': 'timestamp', 'icon': 'mdi:table-clock'})
1135-
self.set_state("predbat.high_rate_export_cost_2", state=rate_average, attributes = {'friendly_name' : 'Next+1 high export rate cost', 'state_class': 'measurement', 'unit_of_measurement': 'p', 'icon': 'mdi:currency-usd'})
1086+
self.set_state("predbat.high_rate_export_cost_2", state=self.rate_export_average, attributes = {'friendly_name' : 'Next+1 high export rate cost', 'state_class': 'measurement', 'unit_of_measurement': 'p', 'icon': 'mdi:currency-usd'})
11361087

1137-
return rates
11381088

11391089
def rate_minmax(self, rates):
11401090
"""
@@ -1222,7 +1172,9 @@ def rate_scan(self, rates, octopus_slots):
12221172

12231173
# Find charging window
12241174
self.low_rates = self.rate_scan_window(rates, rate_low_min_window, rate_average * rate_low_threshold, False)
1175+
return rates
12251176

1177+
def publish_rates_import(self):
12261178
# Output rate info
12271179
if self.low_rates:
12281180
window_n = 0
@@ -1253,13 +1205,11 @@ def rate_scan(self, rates, octopus_slots):
12531205
self.log("No low rate period found")
12541206
self.set_state("predbat.low_rate_start", state='undefined', attributes = {'friendly_name' : 'Next low rate start', 'device_class': 'timestamp', 'icon': 'mdi:table-clock'})
12551207
self.set_state("predbat.low_rate_end", state='undefined', attributes = {'friendly_name' : 'Next low rate end', 'device_class': 'timestamp', 'icon': 'mdi:table-clock'})
1256-
self.set_state("predbat.low_rate_cost", state=rate_average, attributes = {'friendly_name' : 'Next low rate cost', 'state_class': 'measurement', 'unit_of_measurement': 'p', 'icon': 'mdi:currency-usd'})
1208+
self.set_state("predbat.low_rate_cost", state=self.rate_average, attributes = {'friendly_name' : 'Next low rate cost', 'state_class': 'measurement', 'unit_of_measurement': 'p', 'icon': 'mdi:currency-usd'})
12571209
if len(self.low_rates) < 2 and not SIMULATE:
12581210
self.set_state("predbat.low_rate_start_2", state='undefined', attributes = {'friendly_name' : 'Next+1 low rate start', 'device_class': 'timestamp', 'icon': 'mdi:table-clock'})
12591211
self.set_state("predbat.low_rate_end_2", state='undefined', attributes = {'friendly_name' : 'Next+1 low rate end', 'device_class': 'timestamp', 'icon': 'mdi:table-clock'})
1260-
self.set_state("predbat.low_rate_cost_2", state=rate_average, attributes = {'friendly_name' : 'Next+1 low rate cost', 'state_class': 'measurement', 'unit_of_measurement': 'p', 'icon': 'mdi:currency-usd'})
1261-
1262-
return rates
1212+
self.set_state("predbat.low_rate_cost_2", state=self.rate_average, attributes = {'friendly_name' : 'Next+1 low rate cost', 'state_class': 'measurement', 'unit_of_measurement': 'p', 'icon': 'mdi:currency-usd'})
12631213

12641214
def publish_rates(self, rates, export):
12651215
"""
@@ -1272,6 +1222,11 @@ def publish_rates(self, rates, export):
12721222
stamp = minute_timestamp.strftime(TIME_FORMAT)
12731223
rates_time[stamp] = rates[minute]
12741224

1225+
if export:
1226+
self.publish_rates_export()
1227+
else:
1228+
self.publish_rates_import()
1229+
12751230
if not SIMULATE:
12761231
if export:
12771232
self.set_state("predbat.rates_export", state=rates[self.minutes_now], attributes = {'results' : rates_time, 'friendly_name' : 'Export rates', 'state_class' : 'measurement', 'unit_of_measurement': 'p', 'icon': 'mdi:currency-usd'})
@@ -1398,6 +1353,7 @@ def reset(self):
13981353
self.octopus_slots = []
13991354
self.reserve = 0
14001355
self.battery_loss = 1.0
1356+
self.battery_loss_discharge = 1.0
14011357
self.battery_scaling = 1.0
14021358
self.best_soc_min = 0
14031359
self.best_soc_margin = 0
@@ -1744,6 +1700,7 @@ def update_pred(self):
17441700

17451701
# Battery charging options
17461702
self.battery_loss = 1.0 - self.get_arg('battery_loss', 0.05)
1703+
self.battery_loss_discharge = 1.0 - self.get_arg('battery_loss_discharge', 0)
17471704
self.battery_scaling = self.get_arg('battery_scaling', 1.0)
17481705
self.best_soc_margin = self.get_arg('best_soc_margin', 0)
17491706
self.best_soc_min = self.get_arg('best_soc_min', 0.5)
@@ -1837,7 +1794,7 @@ def update_pred(self):
18371794
self.car_charging_energy = {}
18381795
if 'car_charging_energy' in self.args:
18391796
self.car_charging_energy = self.minute_data(self.get_history(entity_id = self.get_arg('car_charging_energy', indirect=False), days = self.days_previous + 1)[0],
1840-
self.days_previous + 1, now_utc, 'state', 'last_updated', backwards=True, smoothing=True, clean_increment=True)
1797+
self.days_previous + 1, now_utc, 'state', 'last_updated', backwards=True, smoothing=True, clean_increment=True, scale=self.get_arg('car_charging_energy_scale', 1.0))
18411798
self.log("Car charging hold {} with energy data".format(self.car_charging_hold))
18421799
else:
18431800
self.log("Car charging hold {} threshold {}".format(self.car_charging_hold, self.car_charging_threshold*60.0))

0 commit comments

Comments
 (0)