Skip to content

Commit 1ae66a6

Browse files
Dynamic load feature (#2702)
* Dynamic load feature * [pre-commit.ci lite] apply automatic fixes * Fixes * Fixes to ge cloud timeout fixes to logfile search on web fixes to code for dynamic load * [pre-commit.ci lite] apply automatic fixes --------- Co-authored-by: pre-commit-ci-lite[bot] <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com>
1 parent 089484f commit 1ae66a6

9 files changed

Lines changed: 219 additions & 78 deletions

File tree

‎apps/predbat/config.py‎

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -504,6 +504,12 @@
504504
"name": "metric_pv_calibration_enable",
505505
"friendly_name": "Enable use of Calibrated PV data",
506506
"type": "switch",
507+
"default": True,
508+
},
509+
{
510+
"name": "metric_dynamic_load_adjust",
511+
"friendly_name": "Dynamic Load Adjust",
512+
"type": "switch",
507513
"default": False,
508514
},
509515
{

‎apps/predbat/energydataservice.py‎

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,6 @@ def fetch_energidataservice_rates(self, entity_id, adjust_key=None):
3434
if data_all:
3535
# Sort to be safe
3636
data_all.sort(key=lambda e: self._parse_iso(e.get("hour")) or datetime.min)
37-
print(data_all)
3837

3938
# Add tariffs (HH:MM → H → HH → raw ISO)
4039
for entry in data_all:
@@ -53,7 +52,6 @@ def fetch_energidataservice_rates(self, entity_id, adjust_key=None):
5352
scale=1.0,
5453
use_cent=use_cent,
5554
)
56-
print(rate_data)
5755

5856
return rate_data
5957

‎apps/predbat/fetch.py‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def step_data_history(
8787
base_offset=None,
8888
flip=False,
8989
load_adjust={},
90+
load_baseline={},
9091
):
9192
"""
9293
Create cached step data for historical array
@@ -132,6 +133,10 @@ def step_data_history(
132133
load_extra = max(load_extra, -value) # Don't allow going to negative load values
133134
values[minute] = dp4((value + load_extra) * scaling_dynamic * scale_today * scale_fixed)
134135

136+
# Apply dynamic baseline
137+
if minute_absolute in load_baseline:
138+
values[minute] = max(values[minute], load_baseline[minute_absolute])
139+
135140
# Simple divergence model keeps the same total but brings PV/Load up and down every 5 minutes
136141
if cloud_factor and cloud_factor > 0:
137142
for minute in range(0, self.forecast_minutes, step):
@@ -954,11 +959,13 @@ def fetch_sensor_data(self):
954959
self.load_minutes, self.load_minutes_age = self.minute_data_load(self.now_utc, "load_today", self.max_days_previous, required_unit="kWh", load_scaling=self.load_scaling)
955960
self.log("Found {} load_today datapoints going back {} days".format(len(self.load_minutes), self.load_minutes_age))
956961
self.load_minutes_now = max(self.load_minutes.get(0, 0) - self.load_minutes.get(self.minutes_now, 0), 0)
962+
self.load_last_period = (self.load_minutes.get(0, 0) - self.load_minutes.get(PREDICT_STEP, 0)) * 60 / PREDICT_STEP
957963
else:
958964
if self.load_forecast:
959965
self.log("Using load forecast from load_forecast sensor")
960966
self.load_minutes_now = self.load_forecast.get(0, 0)
961967
self.load_minutes_age = 0
968+
self.load_last_period = 0
962969
else:
963970
self.log("Error: You have not set load_today or load_forecast, you will have no load data")
964971
self.record_status(message="Error: load_today not set correctly", had_errors=True)
@@ -2063,6 +2070,7 @@ def fetch_config_options(self):
20632070
self.metric_future_rate_offset_export = self.get_arg("metric_future_rate_offset_export")
20642071
self.metric_inday_adjust_damping = self.get_arg("metric_inday_adjust_damping")
20652072
self.metric_pv_calibration_enable = self.get_arg("metric_pv_calibration_enable")
2073+
self.metric_dynamic_load_adjust = self.get_arg("metric_dynamic_load_adjust")
20662074
self.rate_low_threshold = self.get_arg("rate_low_threshold")
20672075
self.rate_high_threshold = self.get_arg("rate_high_threshold")
20682076
self.inverter_soc_reset = self.get_arg("inverter_soc_reset")

‎apps/predbat/gecloud.py‎

Lines changed: 29 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,11 @@
66

77
import requests
88
from datetime import timedelta, datetime, timezone
9-
from utils import str2time, dp1
9+
from utils import str2time, dp1, dp2
1010
import asyncio
1111
import random
1212
import time
13+
from config import PREDICT_STEP
1314

1415
"""
1516
GE Cloud data download
@@ -223,7 +224,7 @@ def wait_api_started(self):
223224
"""
224225
self.log("GECloud: Waiting for API to start")
225226
count = 0
226-
while not self.api_started and count < 120:
227+
while not self.api_started and count < 240:
227228
time.sleep(1)
228229
count += 1
229230
if not self.api_started:
@@ -1332,9 +1333,9 @@ def download_ge_data(self, now_utc):
13321333
url = "https://api.givenergy.cloud/v1/inverter/{}/data-points/{}".format(geserial, datestr)
13331334
while url:
13341335
if "?" in url:
1335-
url += "&pageSize=4096"
1336+
url += "&pageSize=8000"
13361337
else:
1337-
url += "?pageSize=4096"
1338+
url += "?pageSize=8000"
13381339
data = self.get_ge_url(url, headers, now_utc, 30 if days_prev == 0 else 8 * 60)
13391340
darray = data.get("data", None)
13401341
if darray is None:
@@ -1386,8 +1387,32 @@ def download_ge_data(self, now_utc):
13861387
self.pv_today = self.minute_data(mdata, self.max_days_previous, now_utc, "pv", "last_updated", backwards=True, smoothing=True, scale=self.import_export_scaling, clean_increment=True)
13871388

13881389
self.load_minutes_now = self.load_minutes.get(0, 0) - self.load_minutes.get(self.minutes_now, 0)
1390+
self.load_last_period = (self.load_minutes.get(0, 0) - self.load_minutes.get(PREDICT_STEP, 0)) * 60 / PREDICT_STEP
13891391
self.import_today_now = self.import_today.get(0, 0) - self.import_today.get(self.minutes_now, 0)
13901392
self.export_today_now = self.export_today.get(0, 0) - self.export_today.get(self.minutes_now, 0)
13911393
self.pv_today_now = self.pv_today.get(0, 0) - self.pv_today.get(self.minutes_now, 0)
1394+
1395+
self.log("GE GECloud load_last_period is {} kW".format(dp2(self.load_last_period)))
1396+
# More up to date sensors for current values if set
1397+
if "load_today" in self.args:
1398+
load_minutes, load_minutes_age = self.minute_data_load(self.now_utc, "load_today", self.max_days_previous, required_unit="kWh", load_scaling=self.load_scaling)
1399+
self.load_minutes_now = max(load_minutes.get(0, 0) - load_minutes.get(self.minutes_now, 0), 0)
1400+
self.load_last_period = (load_minutes.get(0, 0) - load_minutes.get(PREDICT_STEP, 0)) * 60 / PREDICT_STEP
1401+
self.log("GE GECloud load_last_period from immediate sensor is {} kW".format(dp2(self.load_last_period)))
1402+
1403+
if "import_today" in self.args:
1404+
import_today = self.minute_data_import_export(self.now_utc, "import_today", scale=self.import_export_scaling, required_unit="kWh")
1405+
self.import_today_now = max(import_today.get(0, 0) - import_today.get(self.minutes_now, 0), 0)
1406+
1407+
# Load export today data
1408+
if "export_today" in self.args:
1409+
export_today = self.minute_data_import_export(self.now_utc, "export_today", scale=self.import_export_scaling, required_unit="kWh")
1410+
self.export_today_now = max(export_today.get(0, 0) - export_today.get(self.minutes_now, 0), 0)
1411+
1412+
# PV today data
1413+
if "pv_today" in self.args:
1414+
pv_today = self.minute_data_import_export(self.now_utc, "pv_today", required_unit="kWh")
1415+
self.pv_today_now = max(pv_today.get(0, 0) - pv_today.get(self.minutes_now, 0), 0)
1416+
13921417
self.log("Downloaded {} datapoints from GE going back {} days".format(len(self.load_minutes), self.load_minutes_age))
13931418
return True

‎apps/predbat/plan.py‎

Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@
1212
import traceback
1313
from datetime import datetime, timedelta
1414
from multiprocessing import Pool, cpu_count
15-
from config import PREDICT_STEP, TIME_FORMAT
16-
from utils import calc_percent_limit, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, calc_percent_limit
15+
from config import PREDICT_STEP, TIME_FORMAT, MINUTE_WATT
16+
from utils import calc_percent_limit, dp0, dp1, dp2, dp3, dp4, remove_intersecting_windows, calc_percent_limit, in_car_slot
1717
from prediction import Prediction, wrapped_run_prediction_single, wrapped_run_prediction_charge, wrapped_run_prediction_charge_min_max, wrapped_run_prediction_export, wrapped_run_prediction_charge_min_max
1818

1919
"""
@@ -36,6 +36,43 @@ def get(self):
3636

3737

3838
class Plan:
39+
def dynamic_load(self):
40+
# Last period load analysis
41+
self.load_last_status = "baseline"
42+
if self.load_last_period > self.battery_rate_max_discharge * MINUTE_WATT / 1000:
43+
self.load_last_status = "high"
44+
elif (self.load_last_period < self.battery_rate_max_discharge * 0.9 * MINUTE_WATT / 1000) and (self.load_last_period < self.car_charging_threshold * 0.9):
45+
# Check if the load is less than car charging threshold
46+
self.load_last_status = "low"
47+
else:
48+
self.load_last_status = "baseline"
49+
50+
self.dynamic_load_baseline = {}
51+
if self.metric_dynamic_load_adjust:
52+
minutes_now = self.minutes_now
53+
minutes_end_slot = int((self.minutes_now + 30) / 30) * 30
54+
# When dynamic load is enabled we try can do two things
55+
# 1. Increase the load prediction in the current 30 minute period to match the actual load (if its low)
56+
# 2. If its low and car charging is predicted then cancel off that car slot
57+
if self.load_last_status == "low":
58+
for car_n in range(0, self.num_cars):
59+
for slot in self.car_charging_slots[car_n]:
60+
if (slot["start"] < minutes_now) and (slot["start"] < minutes_end_slot) and (slot["end"] > minutes_now):
61+
# If the slot is within the current 30 minute period
62+
self.log("Dynamic load adjust is cancelling car {} slot {}-{} due to low load".format(car_n + 1, slot["start"], slot["end"]))
63+
slot["kwh"] = 0
64+
if self.load_last_status == "high":
65+
have_printed = False
66+
for minute_absolute in range(minutes_now, minutes_end_slot, PREDICT_STEP):
67+
car_load = sum(in_car_slot(minute_absolute, self.num_cars, self.car_charging_slots))
68+
load_last_period = self.load_last_period / 60 * PREDICT_STEP
69+
load_last_period = max(load_last_period - car_load, 0)
70+
if load_last_period > 0:
71+
if not have_printed:
72+
self.log("Dynamic load adjust is setting load minimum {:.2f}kW at {}".format(load_last_period, self.time_abs_str(minute_absolute)))
73+
have_printed = True
74+
self.dynamic_load_baseline[minute_absolute] = load_last_period
75+
3976
def find_price_levels(
4077
self,
4178
price_set,
@@ -791,6 +828,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
791828
load_scaling_dynamic=self.load_scaling_dynamic,
792829
cloud_factor=self.metric_load_divergence,
793830
load_adjust=self.manual_load_adjust,
831+
load_baseline=self.dynamic_load_baseline,
794832
)
795833
load_minutes_step10 = self.step_data_history(
796834
self.load_minutes,
@@ -803,6 +841,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
803841
load_scaling_dynamic=self.load_scaling_dynamic,
804842
cloud_factor=min(self.metric_load_divergence + 0.5, 1.0) if self.metric_load_divergence else None,
805843
load_adjust=self.manual_load_adjust,
844+
load_baseline=self.dynamic_load_baseline,
806845
)
807846
pv_forecast_minute_step = self.step_data_history(self.pv_forecast_minute, self.minutes_now, forward=True, cloud_factor=self.metric_cloud_coverage)
808847
pv_forecast_minute10_step = self.step_data_history(self.pv_forecast_minute10, self.minutes_now, forward=True, cloud_factor=min(self.metric_cloud_coverage + 0.2, 1.0) if self.metric_cloud_coverage else None, flip=True)
@@ -3306,7 +3345,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
33063345
self.dashboard_item(
33073346
self.prefix + ".export_energy_h0",
33083347
state=dp3(export_kwh_h0),
3309-
attributes={"friendly_name": "Current export kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:transmission-tower-export"},
3348+
attributes={"friendly_name": "Current export", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:transmission-tower-export"},
33103349
)
33113350
self.dashboard_item(
33123351
self.prefix + ".load_energy",
@@ -3323,7 +3362,12 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
33233362
self.dashboard_item(
33243363
self.prefix + ".load_energy_h0",
33253364
state=dp3(load_kwh_h0),
3326-
attributes={"friendly_name": "Current load kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:home-lightning-bolt"},
3365+
attributes={"friendly_name": "Current load", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:home-lightning-bolt"},
3366+
)
3367+
self.dashboard_item(
3368+
self.prefix + ".load_energy_last_period",
3369+
state=dp3(self.load_last_period),
3370+
attributes={"friendly_name": "Last period load", "state_class": "measurement", "unit_of_measurement": "kW", "icon": "mdi:home-lightning-bolt", "status": self.load_last_status},
33273371
)
33283372
self.dashboard_item(
33293373
self.prefix + ".pv_energy",
@@ -3333,7 +3377,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
33333377
self.dashboard_item(
33343378
self.prefix + ".pv_energy_h0",
33353379
state=dp3(pv_kwh_h0),
3336-
attributes={"friendly_name": "Current PV kWh", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:solar-power"},
3380+
attributes={"friendly_name": "Current PV", "state_class": "measurement", "unit_of_measurement": "kWh", "icon": "mdi:solar-power"},
33373381
)
33383382
self.dashboard_item(
33393383
self.prefix + ".import_energy",

‎apps/predbat/predbat.py‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@
3636
import requests
3737
import asyncio
3838

39-
THIS_VERSION = "v8.25.3"
39+
THIS_VERSION = "v8.25.4"
4040

4141
# fmt: off
4242
PREDBAT_FILES = ["predbat.py", "config.py", "prediction.py", "gecloud.py","utils.py", "inverter.py", "ha.py", "download.py", "unit_test.py", "web.py", "web_helper.py", "predheat.py", "futurerate.py", "octopus.py", "solcast.py","execute.py", "plan.py", "fetch.py", "output.py", "userinterface.py", "energydataservice.py", "alertfeed.py", "compare.py", "db_manager.py", "db_engine.py", "plugin_system.py", "ohme.py", "components.py", "fox.py"]
@@ -427,6 +427,9 @@ def reset(self):
427427
self.metric_inday_adjust_damping = 1.0
428428
self.metric_standing_charge = 0.0
429429
self.metric_self_sufficiency = 0.0
430+
self.metric_dynamic_load_adjust = False
431+
self.metric_pv_calibration_enable = True
432+
self.dynamic_load_baseline = {}
430433
self.iboost_value_scaling = 1.0
431434
self.rate_import = {}
432435
self.rate_export = {}
@@ -526,6 +529,8 @@ def reset(self):
526529
self.load_minutes = {}
527530
self.load_minutes_now = 0
528531
self.load_minutes_age = 0
532+
self.load_last_period = 0
533+
self.load_last_status = "baseline"
529534
self.battery_capacity_nominal = False
530535
self.releases = {}
531536
self.balance_inverters_enable = False
@@ -681,6 +686,7 @@ def update_pred(self, scheduled=True):
681686
self.fetch_config_options()
682687
self.fetch_sensor_data()
683688
self.fetch_inverter_data()
689+
self.dynamic_load()
684690

685691
recompute = False
686692
if not scheduled or not self.plan_valid:

‎apps/predbat/prediction.py‎

Lines changed: 3 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
from datetime import timedelta
1212
from config import PREDICT_STEP, RUN_EVERY, TIME_FORMAT
13-
from utils import remove_intersecting_windows, get_charge_rate_curve, get_discharge_rate_curve, find_charge_rate, calc_percent_limit
13+
from utils import remove_intersecting_windows, get_charge_rate_curve, get_discharge_rate_curve, find_charge_rate, calc_percent_limit, in_iboost_slot, in_car_slot
1414

1515

1616
# Only assign globals once to avoid re-creating them with processes are forked
@@ -366,47 +366,6 @@ def find_charge_window_optimised(self, charge_windows, charge_limit, is_export=F
366366
charge_window_optimised[minute] = window_n
367367
return charge_window_optimised
368368

369-
def in_iboost_slot(self, minute):
370-
"""
371-
Is the given minute inside a car slot
372-
"""
373-
load_amount = 0
374-
375-
if self.iboost_plan:
376-
for slot in self.iboost_plan:
377-
start_minutes = slot["start"]
378-
end_minutes = slot["end"]
379-
kwh = slot["kwh"]
380-
slot_minutes = end_minutes - start_minutes
381-
slot_hours = slot_minutes / 60.0
382-
383-
# Return the load in that slot
384-
if minute >= start_minutes and minute < end_minutes:
385-
load_amount = abs(kwh / slot_hours)
386-
break
387-
return load_amount
388-
389-
def in_car_slot(self, minute):
390-
"""
391-
Is the given minute inside a car slot
392-
"""
393-
load_amount = [0 for car_n in range(self.num_cars)]
394-
395-
for car_n in range(self.num_cars):
396-
if self.car_charging_slots[car_n]:
397-
for slot in self.car_charging_slots[car_n]:
398-
start_minutes = slot["start"]
399-
end_minutes = slot["end"]
400-
kwh = slot["kwh"]
401-
slot_minutes = end_minutes - start_minutes
402-
slot_hours = slot_minutes / 60.0
403-
404-
# Return the load in that slot
405-
if minute >= start_minutes and minute < end_minutes:
406-
load_amount[car_n] = abs(kwh / slot_hours)
407-
break
408-
return load_amount
409-
410369
def run_prediction(self, charge_limit, charge_window, export_window, export_limits, pv10, end_record, save=None, step=PREDICT_STEP, cache=False):
411370
"""
412371
Run a prediction scenario given a charge limit, return the results
@@ -679,7 +638,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
679638
car_freeze = False
680639

681640
if car_enable:
682-
car_load = self.in_car_slot(minute_absolute)
641+
car_load = in_car_slot(minute_absolute, self.num_cars, self.car_charging_slots)
683642

684643
# Car charging?
685644
for car_n in range(self.num_cars):
@@ -724,7 +683,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
724683
# IBoost solar diverter on load, don't do on discharge
725684
# IBoost based on plan for given rates
726685
if self.iboost_plan and (self.iboost_on_export or (export_window_n < 0)):
727-
iboost_load = self.in_iboost_slot(minute_absolute) * step / 60.0
686+
iboost_load = in_iboost_slot(minute_absolute, self.iboost_plan) * step / 60.0
728687
iboost_amount = min(iboost_load, self.iboost_max_power * step, max(self.iboost_max_energy - iboost_today_kwh, 0))
729688

730689
# IBoost based on Predbat charging

0 commit comments

Comments
 (0)