Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
f38cb3e
Add additional load forecast support
Scholdan May 6, 2026
26fb172
Add energy mode for additional load forecasts
Scholdan May 6, 2026
c3c6147
Use energy for additional load forecasts
Scholdan May 6, 2026
0474b28
Refresh additional loads on select updates
Scholdan May 6, 2026
da74cc9
Add switches for additional load forecasts
Scholdan May 6, 2026
e0f3097
Add flexible additional load forecasts
Scholdan May 6, 2026
099fb35
Refine dynamic additional load forecasts
Scholdan May 7, 2026
6534171
Fix dynamic load forecast cleanup
Scholdan May 7, 2026
60e33a7
Clean stale dynamic load entities
Scholdan May 7, 2026
aa40a48
Add flexible load candidate diagnostics
Scholdan May 7, 2026
5c706fb
Log flexible load rate diagnostics
Scholdan May 7, 2026
7ad1692
Summarise planned additional loads
Scholdan May 7, 2026
9ca9428
Fix dynamic load forecast start drift
Scholdan May 7, 2026
6bbfb75
Clamp flexible load selection start
Scholdan May 7, 2026
f83770e
Merge branch 'main' into main
Scholdan May 7, 2026
2da36b8
Lock running flexible load forecasts
Scholdan May 7, 2026
48b3fed
Allow flexible load reselection before start
Scholdan May 7, 2026
c851bf9
Fix additional load forecast edge cases
Scholdan May 7, 2026
93dea2b
Show suggested additional loads in summary
Scholdan May 8, 2026
d5e42ef
Document dishwasher request guard
Scholdan May 8, 2026
d85576c
Clarify running additional load summary
Scholdan May 8, 2026
16a8767
Persist dynamic load forecast metadata
Scholdan May 9, 2026
7fea305
Fix additional load forecast cleanup
Scholdan May 11, 2026
ccdb97c
Handle mixed-case state cleanup
Scholdan May 11, 2026
a471d54
Simplify additional load forecast API
Scholdan May 11, 2026
0530f74
Roll flexible deadlines to next reachable time
Scholdan May 29, 2026
c87b018
Simplify dishwasher automation docs
Scholdan Jun 16, 2026
8faf6bb
Persist additional load history for filtering
Scholdan Jun 16, 2026
abef59c
Fix flexible load candidate energy scaling
Scholdan Jun 17, 2026
95ace2a
Re-optimise after flexible load selection
Scholdan Jun 17, 2026
7771406
[pre-commit.ci lite] apply automatic fixes
pre-commit-ci-lite[bot] Jun 17, 2026
6a5a8f4
Score flexible loads with optimiser metric
Scholdan Jun 29, 2026
55c6b96
Merge upstream main
Scholdan Jun 29, 2026
c528792
Fix flexible load prediction unpacking
Scholdan Jul 1, 2026
160ccca
Additional load: persist history via storage and split into its own m…
Scholdan Jul 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ dayname
daynumber
daysymbol
dedup
delayedstart
dend
denorm
derating
Expand All @@ -93,6 +94,7 @@ digestmod
dimport
dischargeenergytotal
dischargefreeze
dishcare
diverter
dlimit
dnoregion
Expand Down
1,273 changes: 1,273 additions & 0 deletions apps/predbat/additional_load.py

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -1157,6 +1157,16 @@
"restore": False,
"api": True,
},
{
"name": "load_forecast_delta_api",
"friendly_name": "Load forecast delta API controls",
"type": "select",
"options": ["off"],
"icon": "mdi:home-lightning-bolt",
"default": "off",
"restore": False,
"api": True,
},
{
"name": "manual_freeze_charge",
"friendly_name": "Manual force charge freeze",
Expand Down Expand Up @@ -2096,6 +2106,8 @@
"type": "sensor_list",
"sensor_type": "dict|list",
},
"house_load_additional_forecast": {"type": "dict_list"},
"house_load_additional_flexible_max_hours": {"type": "integer"},
"ge_cloud_data": {"type": "boolean"},
"ge_cloud_serial": {"type": "string", "empty": False},
"ge_cloud_key": {"type": "string", "empty": False},
Expand Down
10 changes: 10 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,6 +225,9 @@ def get_filtered_load_minute(self, data, minute_previous, historical, step=1, ba
indices = [minute_previous + offset for offset in range(step)]
load_yesterday, load_yesterday_raw = self.get_filtered_load_window(data, indices, step)

subtract_energy = self.get_additional_load_history_energy(minute_previous, historical, step=step)
load_yesterday = max(0, load_yesterday - subtract_energy)

# Apply base load
base_load = self.base_load * step / 60.0
if load_yesterday < base_load:
Expand Down Expand Up @@ -728,6 +731,8 @@ def fetch_sensor_data(self, save=True):
self.load_minutes_age = 0
self.load_forecast = {}
self.load_forecast_array = []
self.house_load_additional_forecast_adjust = {}
self.house_load_additional_forecasts = {}
self.pv_forecast_minute = {}
self.pv_forecast_minute10 = {}
self.load_scaling_dynamic = {}
Expand Down Expand Up @@ -767,6 +772,10 @@ def fetch_sensor_data(self, save=True):
# Fetch extra load forecast
self.load_forecast, self.load_forecast_array = self.fetch_extra_load_forecast(self.now_utc, load_ml_forecast)

# Fetch named additional load forecast deltas
self.house_load_additional_forecast_adjust, self.house_load_additional_forecasts = self.fetch_additional_load_forecast()
self.publish_additional_load_forecasts()

# Load previous load data
if self.get_arg("ge_cloud_data", False):
self.download_ge_data(self.now_utc)
Expand Down Expand Up @@ -2526,6 +2535,7 @@ def fetch_config_options(self):
self.manual_demand_times = self.manual_times("manual_demand")
self.manual_all_times = self.manual_charge_times + self.manual_export_times + self.manual_demand_times + self.manual_freeze_charge_times + self.manual_freeze_export_times
self.manual_api = self.api_select_update("manual_api")
self.load_forecast_delta_api = self.api_select_update("load_forecast_delta_api")
self.manual_import_rates = self.manual_rates("manual_import_rates", default_rate=self.get_arg("manual_import_value"))
self.manual_export_rates = self.manual_rates("manual_export_rates", default_rate=self.get_arg("manual_export_value"))
self.manual_load_adjust = self.manual_rates("manual_load_adjust", default_rate=self.get_arg("manual_load_value"))
Expand Down
21 changes: 18 additions & 3 deletions apps/predbat/ha.py
Original file line number Diff line number Diff line change
Expand Up @@ -890,13 +890,26 @@ def call_service(self, service, **kwargs):
data_frame = {"domain": domain, "service": service, "service_data": data}
return run_async(self.base.trigger_callback(data_frame))

def api_call(self, endpoint, data_in=None, post=False, core=True, silent=False):
def delete_state(self, entity_id):
"""
Delete a state from Home Assistant.
"""
entity_id_lower = entity_id.lower()
self.db_mirror_list.pop(entity_id, None)
self.db_mirror_list.pop(entity_id_lower, None)
self.state_data.pop(entity_id_lower, None)
if self.ha_key:
self.api_call("/api/states/{}".format(entity_id), delete=True)
Comment thread
Scholdan marked this conversation as resolved.
return True

def api_call(self, endpoint, data_in=None, post=False, delete=False, core=True, silent=False):
"""
Make an API call to Home Assistant.

:param endpoint: The API endpoint to call.
:param data_in: The data to send in the body of the request.
:param post: True if this is a POST request, False for GET.
:param delete: True if this is a DELETE request
:param core: True is this is a call to HA Core, False if it is a Supervisor call
:param silent: True if warning message from the API call is to be suppressed
:return: The response from the API.
Expand All @@ -918,7 +931,9 @@ def api_call(self, endpoint, data_in=None, post=False, core=True, silent=False):
"Accept": "application/json",
}
try:
if post:
if delete:
response = requests.delete(url, headers=headers, timeout=TIMEOUT)
elif post:
if data_in:
response = requests.post(url, headers=headers, json=data_in, timeout=TIMEOUT)
else:
Expand All @@ -928,7 +943,7 @@ def api_call(self, endpoint, data_in=None, post=False, core=True, silent=False):
response = requests.get(url, headers=headers, params=data_in, timeout=TIMEOUT)
else:
response = requests.get(url, headers=headers, timeout=TIMEOUT)
data = response.json()
data = {} if delete and not response.text else response.json()
self.api_errors = 0
except requests.exceptions.JSONDecodeError:
if not silent: # suppress warning message for call to get slug id from supervisor because in docker installs this will always error (no supervisor)
Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/output.py
Original file line number Diff line number Diff line change
Expand Up @@ -916,6 +916,8 @@ def short_textual_plan(self, soc_min, soc_min_minute, pv_forecast_minute_step, p
if car_charging_kwh > 0:
sentence += "- Your car is currently charging.\n"

sentence += self.get_additional_load_text()

charge_window_n_next = self.get_next_charge_window(self.minutes_now)
export_window_n_next = self.get_next_export_window(self.minutes_now)
if charge_window_n < 0 and charge_window_n_next >= 0:
Expand Down
147 changes: 55 additions & 92 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -844,6 +844,38 @@ def in_charge_window(self, charge_window, minute_abs):
window_n += 1
return -1

def optimise_best_windows_once(self, metric, metric_keep, debug_mode):
"""
Run one best-window optimisation pass and apply the standard post-processing.
"""
self.optimise_all_windows(metric, metric_keep, debug_mode)
self.update_target_values()
self.charge_limit_best, self.charge_window_best = remove_intersecting_windows(self.charge_limit_best, self.charge_window_best, self.export_limits_best, self.export_window_best)

if self.calculate_best_export and self.export_window_best:
self.export_limits_best, self.export_window_best = self.discard_unused_export_slots(self.export_limits_best, self.export_window_best)
if self.export_window_best:
self.run_prediction(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, False, end_record=self.end_record)
record_export_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.export_window_best), 1)
self.export_window_best, self.export_limits_best = self.clip_export_slots(self.minutes_now, self.predict_soc, self.export_window_best, self.export_limits_best, record_export_windows, PREDICT_STEP)
self.export_limits_best, self.export_window_best = self.discard_unused_export_slots(self.export_limits_best, self.export_window_best)
self.log("Export windows filtered {}".format(self.window_as_text(self.export_window_best, self.export_limits_best)))

if self.calculate_best_charge and self.charge_window_best:
self.run_prediction(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, False, end_record=self.end_record)
self.log("Raw charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))
if self.set_charge_window:
self.charge_limit_best, self.charge_window_best = self.discard_unused_charge_slots(self.charge_limit_best, self.charge_window_best, self.reserve)
record_charge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.charge_window_best), 1)
self.log("Unclipped charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))
self.charge_window_best, self.charge_limit_best = self.clip_charge_slots(self.minutes_now, self.predict_soc, self.charge_window_best, self.charge_limit_best, record_charge_windows, PREDICT_STEP)
if self.set_charge_window:
self.log("Unfiltered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))
self.charge_limit_best, self.charge_window_best = self.discard_unused_charge_slots(self.charge_limit_best, self.charge_window_best, self.reserve)
self.log("Filtered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))
else:
self.log("Unfiltered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))

def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
"""
Calculate the new plan (best)
Expand Down Expand Up @@ -930,6 +962,9 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
# Created optimised step data
self.metric_cloud_coverage = self.get_cloud_factor(self.minutes_now, self.pv_forecast_minute, self.pv_forecast_minute10)
self.metric_load_divergence = self.get_load_divergence(self.minutes_now, self.load_minutes)
load_adjust = self.manual_load_adjust.copy()
for minute, adjustment in self.house_load_additional_forecast_adjust.items():
load_adjust[minute] = load_adjust.get(minute, 0.0) + adjustment
load_minutes_step = self.step_data_history(
self.load_minutes,
self.minutes_now,
Expand All @@ -940,7 +975,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
load_forecast=self.load_forecast,
load_scaling_dynamic=self.load_scaling_dynamic,
cloud_factor=self.metric_load_divergence,
load_adjust=self.manual_load_adjust,
load_adjust=load_adjust,
load_baseline=self.dynamic_load_baseline,
)
load_minutes_step10 = self.step_data_history(
Expand All @@ -953,7 +988,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
load_forecast=self.load_forecast,
load_scaling_dynamic=self.load_scaling_dynamic,
cloud_factor=min(self.metric_load_divergence + 0.5, 1.0) if self.metric_load_divergence else None,
load_adjust=self.manual_load_adjust,
load_adjust=load_adjust,
load_baseline=self.dynamic_load_baseline,
)
pv_forecast_minute_step = self.step_data_history(self.pv_forecast_minute, self.minutes_now, forward=True, cloud_factor=self.metric_cloud_coverage)
Expand Down Expand Up @@ -1013,96 +1048,7 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
# Recomputing the plan
self.log_option_best()

# Full plan
self.optimise_all_windows(metric, metric_keep, debug_mode)

# Update target values, will be refined via clipping
self.update_target_values()

# Remove charge windows that overlap with export windows
self.charge_limit_best, self.charge_window_best = remove_intersecting_windows(self.charge_limit_best, self.charge_window_best, self.export_limits_best, self.export_window_best)

# Filter out any unused export windows
if self.calculate_best_export and self.export_window_best:
# Filter out the windows we disabled
self.export_limits_best, self.export_window_best = self.discard_unused_export_slots(self.export_limits_best, self.export_window_best)

# Clipping windows
if self.export_window_best:
# Re-run prediction to get data for clipping
(
best_metric,
import_kwh_battery,
import_kwh_house,
export_kwh,
soc_min,
soc,
soc_min_minute,
battery_cycle,
metric_keep,
final_iboost,
final_carbon_g,
) = self.run_prediction(
self.charge_limit_best,
self.charge_window_best,
self.export_window_best,
self.export_limits_best,
False,
end_record=self.end_record,
)

# Work out record windows
record_export_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.export_window_best), 1)

# Export slot clipping
self.export_window_best, self.export_limits_best = self.clip_export_slots(self.minutes_now, self.predict_soc, self.export_window_best, self.export_limits_best, record_export_windows, PREDICT_STEP)

# Filter out the windows we disabled during clipping
self.export_limits_best, self.export_window_best = self.discard_unused_export_slots(self.export_limits_best, self.export_window_best)
self.log("Export windows filtered {}".format(self.window_as_text(self.export_window_best, self.export_limits_best)))

# Filter out any unused charge slots
if self.calculate_best_charge and self.charge_window_best:
# Re-run prediction to get data for clipping
(
best_metric,
import_kwh_battery,
import_kwh_house,
export_kwh,
soc_min,
soc,
soc_min_minute,
battery_cycle,
metric_keep,
final_iboost,
final_carbon_g,
) = self.run_prediction(
self.charge_limit_best,
self.charge_window_best,
self.export_window_best,
self.export_limits_best,
False,
end_record=self.end_record,
)
self.log("Raw charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))

# Initial charge slot filter
if self.set_charge_window:
record_charge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.charge_window_best), 1)
self.charge_limit_best, self.charge_window_best = self.discard_unused_charge_slots(self.charge_limit_best, self.charge_window_best, self.reserve)

# Charge slot clipping
record_charge_windows = max(self.max_charge_windows(self.end_record + self.minutes_now, self.charge_window_best), 1)
self.log("Unclipped charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))
self.charge_window_best, self.charge_limit_best = self.clip_charge_slots(self.minutes_now, self.predict_soc, self.charge_window_best, self.charge_limit_best, record_charge_windows, PREDICT_STEP)

if self.set_charge_window:
# Filter out the windows we disabled during clipping
self.log("Unfiltered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))
self.charge_limit_best, self.charge_window_best = self.discard_unused_charge_slots(self.charge_limit_best, self.charge_window_best, self.reserve)
self.log("Filtered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))
else:
self.log("Unfiltered charge windows {} reserve {}".format(self.window_as_text(self.charge_window_best, calc_percent_limit(self.charge_limit_best, self.soc_max)), self.reserve))
self.optimise_best_windows_once(metric, metric_keep, debug_mode)

# Plan comparison
if charge_window_best_prev is not None and not debug_mode:
Expand Down Expand Up @@ -1135,6 +1081,23 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True):
self.plan_last_updated = self.now_utc
self.plan_last_updated_minutes = self.minutes_now

flexible_selected, load_minutes_step, load_minutes_step10 = self.select_flexible_additional_loads(load_minutes_step, load_minutes_step10, pv_forecast_minute_step, pv_forecast_minute10_step)
if flexible_selected:
self.load_minutes_step = load_minutes_step
self.load_minutes_step10 = load_minutes_step10
self.prediction = Prediction(self, pv_forecast_minute_step, pv_forecast_minute10_step, load_minutes_step, load_minutes_step10)
if recompute and self.calculate_best and self.house_load_additional_flexible_selection_changed:
self.log("Re-optimising plan after flexible additional load selection")
metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = self.run_prediction(
self.charge_limit,
self.charge_window,
self.export_window,
self.export_limits,
False,
end_record=self.end_record,
)
self.optimise_best_windows_once(metric, metric_keep, debug_mode)

# Final simulation of base
metric, import_kwh_battery, import_kwh_house, export_kwh, soc_min, soc, soc_min_minute, battery_cycle, metric_keep, final_iboost, final_carbon_g = self.run_prediction(
self.charge_limit, self.charge_window, self.export_window, self.export_limits, False, save="base" if publish else None, end_record=self.end_record
Expand Down
Loading
Loading