From f38cb3e90c31cc53623cd8dfec63e784a0ac8621 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Wed, 6 May 2026 18:47:20 +0200 Subject: [PATCH 01/25] Add additional load forecast support --- apps/predbat/config.py | 1 + apps/predbat/fetch.py | 191 ++++++++++++++++++ apps/predbat/plan.py | 7 +- apps/predbat/predbat.py | 3 + .../tests/test_additional_load_forecast.py | 134 ++++++++++++ apps/predbat/unit_test.py | 2 + apps/predbat/userinterface.py | 30 +++ docs/apps-yaml.md | 43 ++++ docs/manual-api.md | 31 +++ 9 files changed, 440 insertions(+), 2 deletions(-) create mode 100644 apps/predbat/tests/test_additional_load_forecast.py diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 46dde8f7c..31fe9d971 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2043,6 +2043,7 @@ "type": "sensor_list", "sensor_type": "dict|list", }, + "house_load_additional_forecast": {"type": "dict_list"}, "ge_cloud_data": {"type": "boolean"}, "ge_cloud_serial": {"type": "string", "empty": False}, "ge_cloud_key": {"type": "string", "empty": False}, diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index b4150e0b3..3bcd60175 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -26,6 +26,7 @@ from axle import fetch_axle_sessions, load_axle_slot, fetch_axle_active import copy +import re class Fetch: @@ -61,6 +62,190 @@ def get_cloud_factor(self, minutes_now, pv_data, pv_data10): else: return None + def parse_additional_load_weighting(self, weighting, periods): + """ + Parse additional load forecast weighting into per-slot multipliers. + """ + if periods <= 0: + return [] + if weighting is None: + return [1.0 for _ in range(periods)] + if isinstance(weighting, (int, float)): + return [float(weighting) for _ in range(periods)] + + weighting = str(weighting).strip() + if not weighting: + return [1.0 for _ in range(periods)] + + weights = [] + for weight in weighting.split(","): + weight = weight.strip() + if weight == "*": + weights.append(1.0) + else: + try: + weights.append(float(weight)) + except (ValueError, TypeError): + self.log("Warn: Bad weighting {} provided in house_load_additional_forecast, using 1.0".format(weight)) + weights.append(1.0) + + if not weights: + weights = [1.0] + while len(weights) < periods: + weights.append(weights[-1]) + return weights[:periods] + + def get_additional_load_time_minutes(self, load_item, key, default=None): + """ + Resolve a time field on an additional load forecast item to minutes from midnight. + """ + value = load_item.get(key, default) + if value is None: + return None + value = self.resolve_arg(key, value, default) + if value is None: + return None + value = str(value) + if value.count(":") < 2: + value += ":00" + try: + stamp = time_string_to_stamp(value) + except (ValueError, TypeError): + self.log("Warn: Bad {} {} provided in house_load_additional_forecast".format(key, value)) + self.record_status("Warn: Bad {} {} provided in house_load_additional_forecast".format(key, value), had_errors=True) + return None + return minutes_to_time(stamp, time_string_to_stamp("00:00:00")) + + def get_additional_load_float(self, load_item, key, default=0.0): + """ + Resolve a numeric field on an additional load forecast item. + """ + value = load_item.get(key, default) + value = self.resolve_arg(key, value, default) + try: + return float(value) + except (ValueError, TypeError): + self.log("Warn: Bad {} {} provided in house_load_additional_forecast".format(key, value)) + self.record_status("Warn: Bad {} {} provided in house_load_additional_forecast".format(key, value), had_errors=True) + return float(default) + + def additional_load_entity_name(self, name): + """ + Make the binary sensor entity name for a named additional load forecast. + """ + safe_name = re.sub(r"[^a-z0-9_]+", "_", str(name).lower()).strip("_") + if not safe_name: + safe_name = "unknown" + return "binary_sensor.{}_load_forecast_delta_{}".format(self.prefix, safe_name) + + def get_additional_load_forecast_config(self): + """ + Return additional load forecast config with runtime API overrides applied by name. + """ + config = self.get_arg("house_load_additional_forecast", [], indirect=False) + if not config: + config = [] + if isinstance(config, dict): + config = [config] + if isinstance(config, str): + self.log("Warn: house_load_additional_forecast should be a list of dictionaries") + return [] + + forecast_items = [] + for load_item in config: + if not isinstance(load_item, dict): + self.log("Warn: Bad house_load_additional_forecast item {}, expected dictionary".format(load_item)) + continue + forecast_items.append(load_item.copy()) + + for name, override in self.house_load_additional_forecast_overrides.items(): + found = False + for index, load_item in enumerate(forecast_items): + if str(load_item.get("name", "")) == name: + forecast_items[index].update(override) + found = True + break + if not found: + forecast_items.append(override.copy()) + return forecast_items + + def fetch_additional_load_forecast(self): + """ + Build per-minute additional load adjustments from named forecast config. + """ + load_adjust = {} + forecasts = {} + plan_interval = self.get_arg("plan_interval_minutes", 30) + minutes_now_slot = int(self.minutes_now / plan_interval) * plan_interval + + for load_item in self.get_additional_load_forecast_config(): + name = load_item.get("name") + if not name: + self.log("Warn: house_load_additional_forecast item missing name") + continue + name = str(name) + entity_id = self.additional_load_entity_name(name) + start_minutes = self.get_additional_load_time_minutes(load_item, "start_time") + end_minutes = self.get_additional_load_time_minutes(load_item, "end_time") if "end_time" in load_item else None + duration = self.get_additional_load_float(load_item, "duration", 0.0) + load = self.get_additional_load_float(load_item, "load", 0.0) + weighting = self.resolve_arg("weighting", load_item.get("weighting", None), None) + target_times = [] + + if start_minutes is None or load == 0 or duration == 0 and end_minutes is None: + forecasts[name] = {"entity_id": entity_id, "state": "off", "target_times": target_times, "load": load, "duration": duration, "weighting": weighting} + continue + + if end_minutes is None: + end_minutes = start_minutes + int(duration * 60) + elif end_minutes <= start_minutes: + end_minutes += 24 * 60 + + if end_minutes <= minutes_now_slot: + start_minutes += 24 * 60 + end_minutes += 24 * 60 + + periods = int((end_minutes - start_minutes + plan_interval - 1) / plan_interval) + weights = self.parse_additional_load_weighting(weighting, periods) + + for period in range(periods): + slot_start = start_minutes + period * plan_interval + slot_end = min(slot_start + plan_interval, end_minutes) + if slot_end <= minutes_now_slot: + continue + if (slot_start - minutes_now_slot) >= self.forecast_minutes: + continue + energy = dp4(load * weights[period]) + for minute in range(slot_start, slot_end): + load_adjust[minute] = dp4(load_adjust.get(minute, 0.0) + energy) + target_times.append( + { + "start": (self.midnight_utc + timedelta(minutes=slot_start)).isoformat(), + "end": (self.midnight_utc + timedelta(minutes=slot_end)).isoformat(), + "energy": energy, + } + ) + + forecasts[name] = {"entity_id": entity_id, "state": "on" if target_times else "off", "target_times": target_times, "load": load, "duration": duration, "weighting": weighting} + + return load_adjust, forecasts + + def publish_additional_load_forecasts(self): + """ + Publish named additional load forecast binary sensors for visibility and automation targeting. + """ + for name, forecast in self.house_load_additional_forecasts.items(): + attributes = { + "friendly_name": "Predbat load forecast delta {}".format(name), + "icon": "mdi:dishwasher", + "name": name, + "load": forecast.get("load", 0.0), + "duration": forecast.get("duration", 0.0), + "weighting": forecast.get("weighting", None), + "target_times": forecast.get("target_times", []), + } + self.dashboard_item(forecast["entity_id"], state=forecast.get("state", "off"), attributes=attributes) + def filtered_today(self, time_data, resetmidnight=False, stamp=None): """ Grab figure for today (midnight) @@ -700,6 +885,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 = {} @@ -739,6 +926,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) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index da575048b..13a607513 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -930,6 +930,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, @@ -940,7 +943,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( @@ -953,7 +956,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) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 4723d7f56..ffaa3666d 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -465,6 +465,9 @@ def reset(self): self.manual_import_rates = {} self.manual_export_rates = {} self.manual_load_adjust = {} + self.house_load_additional_forecast_adjust = {} + self.house_load_additional_forecasts = {} + self.house_load_additional_forecast_overrides = {} self.config_index = {} self.dashboard_index = [] self.dashboard_index_app = {} diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py new file mode 100644 index 000000000..f3da71dc7 --- /dev/null +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -0,0 +1,134 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +"""Tests for named additional house load forecasts.""" + +from tests.test_infra import run_async + + +def configure_additional_load_test(my_predbat): + """Configure deterministic clock and plan settings for additional load tests.""" + my_predbat.minutes_now = 10 * 60 + my_predbat.plan_interval_minutes = 30 + my_predbat.forecast_minutes = 24 * 60 + my_predbat.args["plan_interval_minutes"] = 30 + my_predbat.house_load_additional_forecast_overrides = {} + + +def check_slot(load_adjust, minute, expected, label): + """Check one generated load adjustment minute value.""" + actual = load_adjust.get(minute, 0.0) + if actual != expected: + print("ERROR: {} expected {} at minute {} got {}".format(label, expected, minute, actual)) + return 1 + return 0 + + +def test_additional_load_disabled(my_predbat): + """Test duration 0 disables an additional load forecast.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 0, "load": 0.5}, + ] + + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + if load_adjust: + print("ERROR: Disabled additional load should not produce adjustments, got {}".format(load_adjust)) + failed = 1 + if forecasts.get("dishwasher", {}).get("state") != "off": + print("ERROR: Disabled additional load should publish off state") + failed = 1 + return failed + + +def test_additional_load_dishwasher_simple(my_predbat): + """Test a simple dishwasher additional load forecast.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "load": 0.5}, + ] + + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + for minute in [20 * 60, 20 * 60 + 30, 21 * 60, 21 * 60 + 30]: + failed |= check_slot(load_adjust, minute, 0.5, "dishwasher simple") + failed |= check_slot(load_adjust, 22 * 60, 0.0, "dishwasher simple end") + if forecasts.get("dishwasher", {}).get("state") != "on": + print("ERROR: Dishwasher additional load should publish on state") + failed = 1 + if len(forecasts.get("dishwasher", {}).get("target_times", [])) != 4: + print("ERROR: Dishwasher target_times should contain 4 slots") + failed = 1 + my_predbat.house_load_additional_forecasts = forecasts + my_predbat.publish_additional_load_forecasts() + sensor = my_predbat.dashboard_values.get("binary_sensor.predbat_load_forecast_delta_dishwasher", {}) + if sensor.get("state") != "on": + print("ERROR: Dishwasher binary sensor should be published on") + failed = 1 + if len(sensor.get("attributes", {}).get("target_times", [])) != 4: + print("ERROR: Dishwasher binary sensor should publish target_times") + failed = 1 + return failed + + +def test_additional_load_dishwasher_weighting(my_predbat): + """Test dishwasher weighting multiplies the per-slot load.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "load": 0.5, "weighting": "2,2,*"}, + ] + + load_adjust, _ = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 20 * 60, 1.0, "dishwasher weighting") + failed |= check_slot(load_adjust, 20 * 60 + 30, 1.0, "dishwasher weighting") + failed |= check_slot(load_adjust, 21 * 60, 0.5, "dishwasher weighting") + failed |= check_slot(load_adjust, 21 * 60 + 30, 0.5, "dishwasher weighting") + return failed + + +def test_additional_load_multiple_and_service_override(my_predbat): + """Test multiple loads add together and service override updates one named load.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "load": 0.5}, + {"name": "heating", "start_time": "20:30", "duration": 1.0, "load": 0.25}, + ] + + load_adjust, _ = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 20 * 60, 0.5, "multiple loads dishwasher") + failed |= check_slot(load_adjust, 20 * 60 + 30, 0.75, "multiple loads overlap") + failed |= check_slot(load_adjust, 21 * 60, 0.75, "multiple loads overlap") + + service_data = { + "domain": "predbat", + "service": "update_load_forecast_delta", + "service_data": {"entity_id": "binary_sensor.predbat_load_forecast_delta_dishwasher", "start_time": "18:00", "duration": 1.0, "load": 0.4}, + } + run_async(my_predbat.trigger_callback(service_data)) + load_adjust, _ = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 18 * 60, 0.4, "service override dishwasher") + failed |= check_slot(load_adjust, 18 * 60 + 30, 0.4, "service override dishwasher") + failed |= check_slot(load_adjust, 20 * 60, 0.0, "service override removed old dishwasher") + failed |= check_slot(load_adjust, 20 * 60 + 30, 0.25, "service override kept heating") + return failed + + +def run_additional_load_forecast_tests(my_predbat): + """Run additional load forecast tests.""" + failed = 0 + print("Test additional load forecast") + failed |= test_additional_load_disabled(my_predbat) + failed |= test_additional_load_dishwasher_simple(my_predbat) + failed |= test_additional_load_dishwasher_weighting(my_predbat) + failed |= test_additional_load_multiple_and_service_override(my_predbat) + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 80745fca1..0693f0b64 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -118,6 +118,7 @@ from tests.test_discard_unused_charge_slots import run_discard_unused_charge_slots_tests from tests.test_discard_unused_export_slots import run_discard_unused_export_slots_tests from tests.test_marginal_costs import test_marginal_costs +from tests.test_additional_load_forecast import run_additional_load_forecast_tests # Mock the components and plugin system @@ -214,6 +215,7 @@ def main(): ("dynamic_load_car", test_dynamic_load_car_slot_cancellation, "Dynamic load car slot cancellation tests", False), ("units", run_test_units, "Unit tests", False), ("manual_api", run_test_manual_api, "Manual API tests", False), + ("additional_load_forecast", run_additional_load_forecast_tests, "Additional load forecast tests", False), ("manual_soc", run_test_manual_soc, "Manual SOC target tests", False), ("manual_times", run_test_manual_times, "Manual times tests", False), ("manual_select", run_test_manual_select, "Manual select tests", False), diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index d2617f4dc..9731c4276 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -858,8 +858,37 @@ async def trigger_callback(self, service_data): # self.log("Trigger callback for {} {}".format(item["domain"], item["service"])) await item["callback"](item["service"], service_data, None) + async def load_forecast_delta_event(self, service, data, kwargs): + """ + Update a named additional load forecast from a Home Assistant service call. + """ + service_data = data.get("service_data", {}) if data else {} + target = data.get("target", {}) if data else {} + entity_id = service_data.get("entity_id", None) or target.get("entity_id", None) + if isinstance(entity_id, list): + entity_id = entity_id[0] if entity_id else None + name = service_data.get("name", None) + if not name and entity_id: + marker = "_load_forecast_delta_" + if marker in entity_id: + name = entity_id.split(marker, 1)[1] + if not name: + self.log("Warn: update_load_forecast_delta called without name or target entity_id") + self.record_status("Warn: update_load_forecast_delta called without name or target entity_id", had_errors=True) + return + + forecast = {"name": str(name)} + for key in ["start_time", "end_time", "duration", "load", "weighting"]: + if key in service_data: + forecast[key] = service_data[key] + self.house_load_additional_forecast_overrides[str(name)] = forecast + self.plan_valid = False + self.update_pending = True + self.log("Updated additional load forecast {} via service {}".format(name, service)) + def define_service_list(self): self.SERVICE_REGISTER_LIST = [ + {"domain": "predbat", "service": "update_load_forecast_delta"}, {"domain": "input_number", "service": "set_value"}, {"domain": "input_number", "service": "increment"}, {"domain": "input_number", "service": "decrement"}, @@ -873,6 +902,7 @@ def define_service_list(self): {"domain": "select", "service": "select_previous"}, ] self.EVENT_LISTEN_LIST = [ + {"domain": "predbat", "service": "update_load_forecast_delta", "callback": self.load_forecast_delta_event}, {"domain": "switch", "service": "turn_on", "callback": self.switch_event}, {"domain": "switch", "service": "turn_off", "callback": self.switch_event}, {"domain": "switch", "service": "toggle", "callback": self.switch_event}, diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index dc79b5795..4f46bb62c 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1628,6 +1628,49 @@ Set **load_forecast_only** to `true` if you do not wish to use the Predbat forec - sensor.givtcp_{geserial}_load_energy_today_kwh_prediction$results ``` +## Additional House Load Forecast + +In addition to the normal historical or ML load forecast, Predbat can add named future load deltas to the forward plan. This is intended for known loads that may not be represented well by history, such as a dishwasher, cooking, hot water, or heating demand. + +Each item in **house_load_additional_forecast** is labelled by **name** and can be updated later from a Home Assistant automation using the published binary sensor for that name. + +```yaml + house_load_additional_forecast: + - name: dishwasher + start_time: "20:00" + duration: 2.0 + load: 0.5 +``` + +The **load** value is kWh per Predbat plan slot, not the total energy for the full duration. With the default 30-minute plan interval, the example above adds 0.5kWh to each of the four slots from 20:00 to 22:00. + +Set **duration** to `0` to leave a named load configured but disabled by default. + +You can optionally set **end_time** instead of **duration**: + +```yaml + house_load_additional_forecast: + - name: cooking + start_time: "18:00" + end_time: "19:30" + load: 0.4 +``` + +You can optionally set **weighting** to multiply the slot load across the duration. A `*` means normal weight `1.0`; if fewer weights are supplied than slots, the final weight is repeated. + +```yaml + house_load_additional_forecast: + - name: dishwasher + start_time: "20:00" + duration: 2.0 + load: 0.5 + weighting: "2,2,*" +``` + +With a 30-minute plan interval this adds 1.0kWh, 1.0kWh, 0.5kWh, and 0.5kWh over the four slots. + +Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. + ## Balance Inverters When you have two or more inverters it's possible they get out of sync so they are at different charge levels or they start to cross-charge (one discharges into another). diff --git a/docs/manual-api.md b/docs/manual-api.md index 47276f29b..c15695780 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -155,3 +155,34 @@ entities: You simply enter the date, start time, end time and load percentage adjustment (e.g. 0.5=50%), then click the 'Execute' button. The load adjustment details will be sent to the Predbat manual API and you will see the load change and a small +/- symbol against the export rate in the Predbat plan. + +## Updating additional house load forecasts + +Named entries configured with [house_load_additional_forecast](apps-yaml.md#additional-house-load-forecast) can be updated from a Home Assistant automation using the Predbat action **predbat.update_load_forecast_delta**. + +For example, to schedule a dishwasher load: + +```yaml +action: predbat.update_load_forecast_delta +data: + start_time: "20:00" + duration: 2.0 + load: 0.5 +target: + entity_id: binary_sensor.predbat_load_forecast_delta_dishwasher +``` + +The **load** value is kWh per Predbat plan slot. With the default 30-minute plan interval, this example adds 0.5kWh to each slot for two hours. + +You can also include **weighting** to model a higher load at the start of a cycle: + +```yaml +action: predbat.update_load_forecast_delta +data: + start_time: "20:00" + duration: 2.0 + load: 0.5 + weighting: "2,2,*" +target: + entity_id: binary_sensor.predbat_load_forecast_delta_dishwasher +``` From 26fb1722911b00bbe4182caf7f85c781e2d05873 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Wed, 6 May 2026 19:19:16 +0200 Subject: [PATCH 02/25] Add energy mode for additional load forecasts --- apps/predbat/config.py | 10 ++ apps/predbat/fetch.py | 82 ++++++++++++++-- apps/predbat/predbat.py | 1 + .../tests/test_additional_load_forecast.py | 94 +++++++++++++++++++ apps/predbat/userinterface.py | 2 +- docs/apps-yaml.md | 45 ++++++++- docs/manual-api.md | 29 +++--- 7 files changed, 236 insertions(+), 27 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 31fe9d971..faa9fae55 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -1133,6 +1133,16 @@ "restore": False, "api": True, }, + { + "name": "load_forecast_delta_api", + "friendly_name": "Load forecast delta API controls", + "type": "select", + "options": ["off"], + "icon": "mdi:dishwasher", + "default": "off", + "restore": False, + "api": True, + }, { "name": "manual_freeze_charge", "friendly_name": "Manual force charge freeze", diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 3bcd60175..4f185942a 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -78,7 +78,10 @@ def parse_additional_load_weighting(self, weighting, periods): return [1.0 for _ in range(periods)] weights = [] - for weight in weighting.split(","): + weight_separator = "," + if "|" in weighting: + weight_separator = "|" + for weight in weighting.split(weight_separator): weight = weight.strip() if weight == "*": weights.append(1.0) @@ -121,6 +124,11 @@ def get_additional_load_float(self, load_item, key, default=0.0): Resolve a numeric field on an additional load forecast item. """ value = load_item.get(key, default) + if isinstance(value, str): + try: + return float(value) + except (ValueError, TypeError): + pass value = self.resolve_arg(key, value, default) try: return float(value) @@ -158,7 +166,29 @@ def get_additional_load_forecast_config(self): continue forecast_items.append(load_item.copy()) - for name, override in self.house_load_additional_forecast_overrides.items(): + api_forecast_overrides = {} + api_overrides = self.api_select_update("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else [] + for api_command in api_overrides: + if "?" not in api_command: + self.log("Warn: Bad load_forecast_delta_api command {}, expected name?start_time=...&duration=...".format(api_command)) + continue + name, command_args = api_command.split("?", 1) + if not name: + self.log("Warn: Bad load_forecast_delta_api command {}, missing name".format(api_command)) + continue + override = {"name": name} + for arg in command_args.split("&"): + arg_split = arg.split("=", 1) + if len(arg_split) > 1: + override[arg_split[0]] = arg_split[1] + else: + override[arg_split[0]] = True + api_forecast_overrides[str(name)] = override + + runtime_overrides = {} + runtime_overrides.update(api_forecast_overrides) + runtime_overrides.update(self.house_load_additional_forecast_overrides) + for name, override in runtime_overrides.items(): found = False for index, load_item in enumerate(forecast_items): if str(load_item.get("name", "")) == name: @@ -189,11 +219,25 @@ def fetch_additional_load_forecast(self): end_minutes = self.get_additional_load_time_minutes(load_item, "end_time") if "end_time" in load_item else None duration = self.get_additional_load_float(load_item, "duration", 0.0) load = self.get_additional_load_float(load_item, "load", 0.0) + energy_total = self.get_additional_load_float(load_item, "energy", 0.0) if "energy" in load_item else None weighting = self.resolve_arg("weighting", load_item.get("weighting", None), None) target_times = [] - - if start_minutes is None or load == 0 or duration == 0 and end_minutes is None: - forecasts[name] = {"entity_id": entity_id, "state": "off", "target_times": target_times, "load": load, "duration": duration, "weighting": weighting} + load_mode = "total_energy" if energy_total is not None else "per_slot" + + if start_minutes is None or (energy_total is None and load == 0) or (energy_total == 0) or duration == 0 and end_minutes is None: + forecasts[name] = { + "entity_id": entity_id, + "state": "off", + "target_times": target_times, + "load": load, + "energy": energy_total, + "duration": duration, + "weighting": weighting, + "load_mode": load_mode, + "plan_interval_minutes": plan_interval, + "slots": 0, + "total_energy": 0.0, + } continue if end_minutes is None: @@ -207,6 +251,8 @@ def fetch_additional_load_forecast(self): periods = int((end_minutes - start_minutes + plan_interval - 1) / plan_interval) weights = self.parse_additional_load_weighting(weighting, periods) + weight_total = sum(weights) + total_energy = 0.0 for period in range(periods): slot_start = start_minutes + period * plan_interval @@ -215,7 +261,11 @@ def fetch_additional_load_forecast(self): continue if (slot_start - minutes_now_slot) >= self.forecast_minutes: continue - energy = dp4(load * weights[period]) + if energy_total is not None: + energy = dp4(energy_total * weights[period] / weight_total) if weight_total else 0.0 + else: + energy = dp4(load * weights[period]) + total_energy += energy for minute in range(slot_start, slot_end): load_adjust[minute] = dp4(load_adjust.get(minute, 0.0) + energy) target_times.append( @@ -226,7 +276,19 @@ def fetch_additional_load_forecast(self): } ) - forecasts[name] = {"entity_id": entity_id, "state": "on" if target_times else "off", "target_times": target_times, "load": load, "duration": duration, "weighting": weighting} + forecasts[name] = { + "entity_id": entity_id, + "state": "on" if target_times else "off", + "target_times": target_times, + "load": load, + "energy": energy_total, + "duration": duration, + "weighting": weighting, + "load_mode": load_mode, + "plan_interval_minutes": plan_interval, + "slots": len(target_times), + "total_energy": dp4(total_energy), + } return load_adjust, forecasts @@ -240,8 +302,13 @@ def publish_additional_load_forecasts(self): "icon": "mdi:dishwasher", "name": name, "load": forecast.get("load", 0.0), + "energy": forecast.get("energy", None), "duration": forecast.get("duration", 0.0), "weighting": forecast.get("weighting", None), + "load_mode": forecast.get("load_mode", "per_slot"), + "plan_interval_minutes": forecast.get("plan_interval_minutes", self.plan_interval_minutes), + "slots": forecast.get("slots", 0), + "total_energy": forecast.get("total_energy", 0.0), "target_times": forecast.get("target_times", []), } self.dashboard_item(forecast["entity_id"], state=forecast.get("state", "off"), attributes=attributes) @@ -2528,6 +2595,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")) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index ffaa3666d..f8042d635 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -462,6 +462,7 @@ def reset(self): self.manual_demand_times = [] self.manual_all_times = [] self.manual_api = [] + self.load_forecast_delta_api = [] self.manual_import_rates = {} self.manual_export_rates = {} self.manual_load_adjust = {} diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index f3da71dc7..15cb02ed8 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -95,6 +95,51 @@ def test_additional_load_dishwasher_weighting(my_predbat): return failed +def test_additional_load_dishwasher_total_energy(my_predbat): + """Test dishwasher total energy is distributed across plan slots.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.plan_interval_minutes = 15 + my_predbat.args["plan_interval_minutes"] = 15 + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "energy": 1.2}, + ] + + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + for minute in [20 * 60, 20 * 60 + 15, 20 * 60 + 30, 20 * 60 + 45, 21 * 60, 21 * 60 + 15, 21 * 60 + 30, 21 * 60 + 45]: + failed |= check_slot(load_adjust, minute, 0.15, "dishwasher total energy") + forecast = forecasts.get("dishwasher", {}) + if forecast.get("load_mode") != "total_energy": + print("ERROR: Dishwasher energy mode should be total_energy") + failed = 1 + if forecast.get("slots") != 8: + print("ERROR: Dishwasher energy mode should create 8 slots, got {}".format(forecast.get("slots"))) + failed = 1 + if forecast.get("total_energy") != 1.2: + print("ERROR: Dishwasher total energy should be 1.2, got {}".format(forecast.get("total_energy"))) + failed = 1 + return failed + + +def test_additional_load_dishwasher_total_energy_weighting(my_predbat): + """Test total energy weighting redistributes, rather than increases, energy.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "energy": 1.2, "weighting": "2,2,*"}, + ] + + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 20 * 60, 0.4, "dishwasher total energy weighting") + failed |= check_slot(load_adjust, 20 * 60 + 30, 0.4, "dishwasher total energy weighting") + failed |= check_slot(load_adjust, 21 * 60, 0.2, "dishwasher total energy weighting") + failed |= check_slot(load_adjust, 21 * 60 + 30, 0.2, "dishwasher total energy weighting") + if forecasts.get("dishwasher", {}).get("total_energy") != 1.2: + print("ERROR: Dishwasher weighted total energy should remain 1.2") + failed = 1 + return failed + + def test_additional_load_multiple_and_service_override(my_predbat): """Test multiple loads add together and service override updates one named load.""" failed = 0 @@ -123,6 +168,51 @@ def test_additional_load_multiple_and_service_override(my_predbat): return failed +def test_additional_load_select_api_override(my_predbat): + """Test standard HA select API updates a named load forecast.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 0, "energy": 0}, + ] + + my_predbat.api_select("load_forecast_delta_api", "dishwasher?start_time=18:00&duration=2.0&energy=1.2") + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 18 * 60, 0.3, "select API dishwasher") + failed |= check_slot(load_adjust, 18 * 60 + 30, 0.3, "select API dishwasher") + failed |= check_slot(load_adjust, 19 * 60, 0.3, "select API dishwasher") + failed |= check_slot(load_adjust, 19 * 60 + 30, 0.3, "select API dishwasher") + forecast = forecasts.get("dishwasher", {}) + if forecast.get("state") != "on" or forecast.get("total_energy") != 1.2: + print("ERROR: Select API dishwasher forecast not enabled correctly: {}".format(forecast)) + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + +def test_additional_load_select_api_weighting(my_predbat): + """Test select API accepts pipe-separated weighting.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [] + + my_predbat.api_select("load_forecast_delta_api", "dishwasher?start_time=18:00&duration=2.0&energy=1.2&weighting=2|2|*") + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 18 * 60, 0.4, "select API weighting dishwasher") + failed |= check_slot(load_adjust, 18 * 60 + 30, 0.4, "select API weighting dishwasher") + failed |= check_slot(load_adjust, 19 * 60, 0.2, "select API weighting dishwasher") + failed |= check_slot(load_adjust, 19 * 60 + 30, 0.2, "select API weighting dishwasher") + if forecasts.get("dishwasher", {}).get("total_energy") != 1.2: + print("ERROR: Select API weighted dishwasher total energy should remain 1.2") + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + def run_additional_load_forecast_tests(my_predbat): """Run additional load forecast tests.""" failed = 0 @@ -130,5 +220,9 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_disabled(my_predbat) failed |= test_additional_load_dishwasher_simple(my_predbat) failed |= test_additional_load_dishwasher_weighting(my_predbat) + failed |= test_additional_load_dishwasher_total_energy(my_predbat) + failed |= test_additional_load_dishwasher_total_energy_weighting(my_predbat) failed |= test_additional_load_multiple_and_service_override(my_predbat) + failed |= test_additional_load_select_api_override(my_predbat) + failed |= test_additional_load_select_api_weighting(my_predbat) return failed diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 9731c4276..780047f2e 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -878,7 +878,7 @@ async def load_forecast_delta_event(self, service, data, kwargs): return forecast = {"name": str(name)} - for key in ["start_time", "end_time", "duration", "load", "weighting"]: + for key in ["start_time", "end_time", "duration", "load", "energy", "weighting"]: if key in service_data: forecast[key] = service_data[key] self.house_load_additional_forecast_overrides[str(name)] = forecast diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 4f46bb62c..35dcfe0a6 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1632,7 +1632,21 @@ Set **load_forecast_only** to `true` if you do not wish to use the Predbat forec In addition to the normal historical or ML load forecast, Predbat can add named future load deltas to the forward plan. This is intended for known loads that may not be represented well by history, such as a dishwasher, cooking, hot water, or heating demand. -Each item in **house_load_additional_forecast** is labelled by **name** and can be updated later from a Home Assistant automation using the published binary sensor for that name. +Each item in **house_load_additional_forecast** is labelled by **name** and can be updated later from a Home Assistant automation using **select.predbat_load_forecast_delta_api**. + +For appliances where you know the total cycle energy, use **energy** in kWh. Predbat will divide the total across the generated plan slots: + +```yaml + house_load_additional_forecast: + - name: dishwasher + start_time: "20:00" + duration: 2.0 + energy: 1.2 +``` + +With a 15-minute plan interval this adds 0.15kWh to each of the eight slots from 20:00 to 22:00, for a total of 1.2kWh. + +Alternatively, use **load** when you want to set the kWh for each Predbat plan slot directly: ```yaml house_load_additional_forecast: @@ -1642,7 +1656,7 @@ Each item in **house_load_additional_forecast** is labelled by **name** and can load: 0.5 ``` -The **load** value is kWh per Predbat plan slot, not the total energy for the full duration. With the default 30-minute plan interval, the example above adds 0.5kWh to each of the four slots from 20:00 to 22:00. +The **load** value is kWh per Predbat plan slot, not the total energy for the full duration. With the default 30-minute plan interval, the example above adds 0.5kWh to each of the four slots from 20:00 to 22:00, for a total of 2.0kWh. With a 15-minute plan interval it would add 0.5kWh to each of eight slots, for a total of 4.0kWh. Set **duration** to `0` to leave a named load configured but disabled by default. @@ -1656,7 +1670,7 @@ You can optionally set **end_time** instead of **duration**: load: 0.4 ``` -You can optionally set **weighting** to multiply the slot load across the duration. A `*` means normal weight `1.0`; if fewer weights are supplied than slots, the final weight is repeated. +You can optionally set **weighting** to change the load profile across the duration. A `*` means normal weight `1.0`; if fewer weights are supplied than slots, the final weight is repeated. With **load**, weighting multiplies the per-slot load. With **energy**, weighting redistributes the total energy without changing the total. ```yaml house_load_additional_forecast: @@ -1669,7 +1683,30 @@ You can optionally set **weighting** to multiply the slot load across the durati With a 30-minute plan interval this adds 1.0kWh, 1.0kWh, 0.5kWh, and 0.5kWh over the four slots. -Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. +Using total **energy** with weighting: + +```yaml + house_load_additional_forecast: + - name: dishwasher + start_time: "20:00" + duration: 2.0 + energy: 1.2 + weighting: "2,2,*" +``` + +With a 30-minute plan interval this redistributes 1.2kWh as 0.4kWh, 0.4kWh, 0.2kWh, and 0.2kWh. + +Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. The sensor attributes also show **load_mode**, **plan_interval_minutes**, **slots**, and **total_energy** so you can confirm how much load will be added. + +To update a named load from a Home Assistant automation, call **select.select_option** on **select.predbat_load_forecast_delta_api** with the format `name?start_time=HH:MM&duration=hours&energy=kWh`: + +```yaml +action: select.select_option +target: + entity_id: select.predbat_load_forecast_delta_api +data: + option: "dishwasher?start_time=20:00&duration=2.0&energy=1.2" +``` ## Balance Inverters diff --git a/docs/manual-api.md b/docs/manual-api.md index c15695780..96d64493f 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -158,31 +158,30 @@ The load adjustment details will be sent to the Predbat manual API and you will ## Updating additional house load forecasts -Named entries configured with [house_load_additional_forecast](apps-yaml.md#additional-house-load-forecast) can be updated from a Home Assistant automation using the Predbat action **predbat.update_load_forecast_delta**. +Named entries configured with [house_load_additional_forecast](apps-yaml.md#additional-house-load-forecast) can be updated from a Home Assistant automation using **select.predbat_load_forecast_delta_api**. This uses Home Assistant's standard **select.select_option** action, so it is visible in Developer Tools and automations. For example, to schedule a dishwasher load: ```yaml -action: predbat.update_load_forecast_delta -data: - start_time: "20:00" - duration: 2.0 - load: 0.5 +action: select.select_option target: - entity_id: binary_sensor.predbat_load_forecast_delta_dishwasher + entity_id: select.predbat_load_forecast_delta_api +data: + option: "dishwasher?start_time=20:00&duration=2.0&energy=1.2" ``` -The **load** value is kWh per Predbat plan slot. With the default 30-minute plan interval, this example adds 0.5kWh to each slot for two hours. +The **energy** value is the total kWh across the full duration. Predbat divides it across the generated plan slots. + +You can use **load** instead when you want to set kWh per Predbat plan slot directly. With the default 30-minute plan interval, `load: 0.5` adds 0.5kWh to each slot for two hours. You can also include **weighting** to model a higher load at the start of a cycle: ```yaml -action: predbat.update_load_forecast_delta -data: - start_time: "20:00" - duration: 2.0 - load: 0.5 - weighting: "2,2,*" +action: select.select_option target: - entity_id: binary_sensor.predbat_load_forecast_delta_dishwasher + entity_id: select.predbat_load_forecast_delta_api +data: + option: "dishwasher?start_time=20:00&duration=2.0&energy=1.2&weighting=2|2|*" ``` + +With **energy**, weighting redistributes the total energy without changing the total. With **load**, weighting multiplies the per-slot load. Use `|` as the weighting separator when sending commands through **select.predbat_load_forecast_delta_api**, because Home Assistant select options are stored as comma-separated values internally. From c3c6147400718a23331b2862690eb189fb7715ed Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Wed, 6 May 2026 19:40:45 +0200 Subject: [PATCH 03/25] Use energy for additional load forecasts --- apps/predbat/fetch.py | 16 +++++------ .../tests/test_additional_load_forecast.py | 28 +++++++++---------- apps/predbat/userinterface.py | 2 +- docs/apps-yaml.md | 18 ++++++------ docs/manual-api.md | 4 +-- 5 files changed, 34 insertions(+), 34 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 4f185942a..571135b20 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -218,19 +218,19 @@ def fetch_additional_load_forecast(self): start_minutes = self.get_additional_load_time_minutes(load_item, "start_time") end_minutes = self.get_additional_load_time_minutes(load_item, "end_time") if "end_time" in load_item else None duration = self.get_additional_load_float(load_item, "duration", 0.0) - load = self.get_additional_load_float(load_item, "load", 0.0) + slot_energy = self.get_additional_load_float(load_item, "slot_energy", 0.0) energy_total = self.get_additional_load_float(load_item, "energy", 0.0) if "energy" in load_item else None weighting = self.resolve_arg("weighting", load_item.get("weighting", None), None) target_times = [] - load_mode = "total_energy" if energy_total is not None else "per_slot" + load_mode = "total_energy" if energy_total is not None else "slot_energy" - if start_minutes is None or (energy_total is None and load == 0) or (energy_total == 0) or duration == 0 and end_minutes is None: + if start_minutes is None or (energy_total is None and slot_energy == 0) or (energy_total == 0) or duration == 0 and end_minutes is None: forecasts[name] = { "entity_id": entity_id, "state": "off", "target_times": target_times, - "load": load, "energy": energy_total, + "slot_energy": slot_energy, "duration": duration, "weighting": weighting, "load_mode": load_mode, @@ -264,7 +264,7 @@ def fetch_additional_load_forecast(self): if energy_total is not None: energy = dp4(energy_total * weights[period] / weight_total) if weight_total else 0.0 else: - energy = dp4(load * weights[period]) + energy = dp4(slot_energy * weights[period]) total_energy += energy for minute in range(slot_start, slot_end): load_adjust[minute] = dp4(load_adjust.get(minute, 0.0) + energy) @@ -280,8 +280,8 @@ def fetch_additional_load_forecast(self): "entity_id": entity_id, "state": "on" if target_times else "off", "target_times": target_times, - "load": load, "energy": energy_total, + "slot_energy": slot_energy, "duration": duration, "weighting": weighting, "load_mode": load_mode, @@ -301,11 +301,11 @@ def publish_additional_load_forecasts(self): "friendly_name": "Predbat load forecast delta {}".format(name), "icon": "mdi:dishwasher", "name": name, - "load": forecast.get("load", 0.0), "energy": forecast.get("energy", None), + "slot_energy": forecast.get("slot_energy", 0.0), "duration": forecast.get("duration", 0.0), "weighting": forecast.get("weighting", None), - "load_mode": forecast.get("load_mode", "per_slot"), + "load_mode": forecast.get("load_mode", "total_energy"), "plan_interval_minutes": forecast.get("plan_interval_minutes", self.plan_interval_minutes), "slots": forecast.get("slots", 0), "total_energy": forecast.get("total_energy", 0.0), diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 15cb02ed8..7aadfe6e2 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -36,7 +36,7 @@ def test_additional_load_disabled(my_predbat): failed = 0 configure_additional_load_test(my_predbat) my_predbat.args["house_load_additional_forecast"] = [ - {"name": "dishwasher", "start_time": "20:00", "duration": 0, "load": 0.5}, + {"name": "dishwasher", "start_time": "20:00", "duration": 0, "energy": 1.2}, ] load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() @@ -50,11 +50,11 @@ def test_additional_load_disabled(my_predbat): def test_additional_load_dishwasher_simple(my_predbat): - """Test a simple dishwasher additional load forecast.""" + """Test a simple dishwasher total energy forecast.""" failed = 0 configure_additional_load_test(my_predbat) my_predbat.args["house_load_additional_forecast"] = [ - {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "load": 0.5}, + {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "energy": 2.0}, ] load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() @@ -79,19 +79,19 @@ def test_additional_load_dishwasher_simple(my_predbat): return failed -def test_additional_load_dishwasher_weighting(my_predbat): - """Test dishwasher weighting multiplies the per-slot load.""" +def test_additional_load_slot_energy_weighting(my_predbat): + """Test advanced slot energy weighting multiplies the per-slot energy.""" failed = 0 configure_additional_load_test(my_predbat) my_predbat.args["house_load_additional_forecast"] = [ - {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "load": 0.5, "weighting": "2,2,*"}, + {"name": "heating", "start_time": "20:00", "duration": 2.0, "slot_energy": 0.5, "weighting": "2,2,*"}, ] load_adjust, _ = my_predbat.fetch_additional_load_forecast() - failed |= check_slot(load_adjust, 20 * 60, 1.0, "dishwasher weighting") - failed |= check_slot(load_adjust, 20 * 60 + 30, 1.0, "dishwasher weighting") - failed |= check_slot(load_adjust, 21 * 60, 0.5, "dishwasher weighting") - failed |= check_slot(load_adjust, 21 * 60 + 30, 0.5, "dishwasher weighting") + failed |= check_slot(load_adjust, 20 * 60, 1.0, "slot energy weighting") + failed |= check_slot(load_adjust, 20 * 60 + 30, 1.0, "slot energy weighting") + failed |= check_slot(load_adjust, 21 * 60, 0.5, "slot energy weighting") + failed |= check_slot(load_adjust, 21 * 60 + 30, 0.5, "slot energy weighting") return failed @@ -145,8 +145,8 @@ def test_additional_load_multiple_and_service_override(my_predbat): failed = 0 configure_additional_load_test(my_predbat) my_predbat.args["house_load_additional_forecast"] = [ - {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "load": 0.5}, - {"name": "heating", "start_time": "20:30", "duration": 1.0, "load": 0.25}, + {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "energy": 2.0}, + {"name": "heating", "start_time": "20:30", "duration": 1.0, "energy": 0.5}, ] load_adjust, _ = my_predbat.fetch_additional_load_forecast() @@ -157,7 +157,7 @@ def test_additional_load_multiple_and_service_override(my_predbat): service_data = { "domain": "predbat", "service": "update_load_forecast_delta", - "service_data": {"entity_id": "binary_sensor.predbat_load_forecast_delta_dishwasher", "start_time": "18:00", "duration": 1.0, "load": 0.4}, + "service_data": {"entity_id": "binary_sensor.predbat_load_forecast_delta_dishwasher", "start_time": "18:00", "duration": 1.0, "energy": 0.8}, } run_async(my_predbat.trigger_callback(service_data)) load_adjust, _ = my_predbat.fetch_additional_load_forecast() @@ -219,7 +219,7 @@ def run_additional_load_forecast_tests(my_predbat): print("Test additional load forecast") failed |= test_additional_load_disabled(my_predbat) failed |= test_additional_load_dishwasher_simple(my_predbat) - failed |= test_additional_load_dishwasher_weighting(my_predbat) + failed |= test_additional_load_slot_energy_weighting(my_predbat) failed |= test_additional_load_dishwasher_total_energy(my_predbat) failed |= test_additional_load_dishwasher_total_energy_weighting(my_predbat) failed |= test_additional_load_multiple_and_service_override(my_predbat) diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 780047f2e..54cc68576 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -878,7 +878,7 @@ async def load_forecast_delta_event(self, service, data, kwargs): return forecast = {"name": str(name)} - for key in ["start_time", "end_time", "duration", "load", "energy", "weighting"]: + for key in ["start_time", "end_time", "duration", "energy", "slot_energy", "weighting"]: if key in service_data: forecast[key] = service_data[key] self.house_load_additional_forecast_overrides[str(name)] = forecast diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 35dcfe0a6..51f25f1da 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1646,17 +1646,17 @@ For appliances where you know the total cycle energy, use **energy** in kWh. Pre With a 15-minute plan interval this adds 0.15kWh to each of the eight slots from 20:00 to 22:00, for a total of 1.2kWh. -Alternatively, use **load** when you want to set the kWh for each Predbat plan slot directly: +For advanced cases, use **slot_energy** when you want to set the kWh for each Predbat plan slot directly: ```yaml house_load_additional_forecast: - - name: dishwasher + - name: heating start_time: "20:00" duration: 2.0 - load: 0.5 + slot_energy: 0.5 ``` -The **load** value is kWh per Predbat plan slot, not the total energy for the full duration. With the default 30-minute plan interval, the example above adds 0.5kWh to each of the four slots from 20:00 to 22:00, for a total of 2.0kWh. With a 15-minute plan interval it would add 0.5kWh to each of eight slots, for a total of 4.0kWh. +The **slot_energy** value is kWh per Predbat plan slot, not the total energy for the full duration. With the default 30-minute plan interval, the example above adds 0.5kWh to each of the four slots from 20:00 to 22:00, for a total of 2.0kWh. With a 15-minute plan interval it would add 0.5kWh to each of eight slots, for a total of 4.0kWh. Set **duration** to `0` to leave a named load configured but disabled by default. @@ -1667,17 +1667,17 @@ You can optionally set **end_time** instead of **duration**: - name: cooking start_time: "18:00" end_time: "19:30" - load: 0.4 + energy: 1.2 ``` -You can optionally set **weighting** to change the load profile across the duration. A `*` means normal weight `1.0`; if fewer weights are supplied than slots, the final weight is repeated. With **load**, weighting multiplies the per-slot load. With **energy**, weighting redistributes the total energy without changing the total. +You can optionally set **weighting** to change the load profile across the duration. A `*` means normal weight `1.0`; if fewer weights are supplied than slots, the final weight is repeated. With **energy**, weighting redistributes the total energy without changing the total. With **slot_energy**, weighting multiplies the per-slot energy. ```yaml house_load_additional_forecast: - - name: dishwasher + - name: heating start_time: "20:00" duration: 2.0 - load: 0.5 + slot_energy: 0.5 weighting: "2,2,*" ``` @@ -1696,7 +1696,7 @@ Using total **energy** with weighting: With a 30-minute plan interval this redistributes 1.2kWh as 0.4kWh, 0.4kWh, 0.2kWh, and 0.2kWh. -Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. The sensor attributes also show **load_mode**, **plan_interval_minutes**, **slots**, and **total_energy** so you can confirm how much load will be added. +Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. The sensor attributes also show **energy**, **slot_energy**, **load_mode**, **plan_interval_minutes**, **slots**, and **total_energy** so you can confirm how much load will be added. To update a named load from a Home Assistant automation, call **select.select_option** on **select.predbat_load_forecast_delta_api** with the format `name?start_time=HH:MM&duration=hours&energy=kWh`: diff --git a/docs/manual-api.md b/docs/manual-api.md index 96d64493f..43893def2 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -172,7 +172,7 @@ data: The **energy** value is the total kWh across the full duration. Predbat divides it across the generated plan slots. -You can use **load** instead when you want to set kWh per Predbat plan slot directly. With the default 30-minute plan interval, `load: 0.5` adds 0.5kWh to each slot for two hours. +For advanced cases, you can use **slot_energy** instead when you want to set kWh per Predbat plan slot directly. With the default 30-minute plan interval, `slot_energy: 0.5` adds 0.5kWh to each slot for two hours. You can also include **weighting** to model a higher load at the start of a cycle: @@ -184,4 +184,4 @@ data: option: "dishwasher?start_time=20:00&duration=2.0&energy=1.2&weighting=2|2|*" ``` -With **energy**, weighting redistributes the total energy without changing the total. With **load**, weighting multiplies the per-slot load. Use `|` as the weighting separator when sending commands through **select.predbat_load_forecast_delta_api**, because Home Assistant select options are stored as comma-separated values internally. +With **energy**, weighting redistributes the total energy without changing the total. With **slot_energy**, weighting multiplies the per-slot energy. Use `|` as the weighting separator when sending commands through **select.predbat_load_forecast_delta_api**, because Home Assistant select options are stored as comma-separated values internally. From 0474b2839424e74eaf546d7a2869880c9f02b456 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Wed, 6 May 2026 19:45:19 +0200 Subject: [PATCH 04/25] Refresh additional loads on select updates --- apps/predbat/fetch.py | 63 +++++++++++++------ .../tests/test_additional_load_forecast.py | 32 ++++++++++ apps/predbat/userinterface.py | 7 ++- 3 files changed, 80 insertions(+), 22 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 571135b20..3020a4ce9 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -146,6 +146,48 @@ def additional_load_entity_name(self, name): safe_name = "unknown" return "binary_sensor.{}_load_forecast_delta_{}".format(self.prefix, safe_name) + def parse_additional_load_api_command(self, api_command): + """ + Parse one load_forecast_delta_api command into a forecast override. + """ + if "?" not in api_command: + self.log("Warn: Bad load_forecast_delta_api command {}, expected name?start_time=...&duration=...".format(api_command)) + return None + + name, command_args = api_command.split("?", 1) + if not name: + self.log("Warn: Bad load_forecast_delta_api command {}, missing name".format(api_command)) + return None + + override = {"name": name} + for arg in command_args.split("&"): + arg_split = arg.split("=", 1) + if len(arg_split) > 1: + override[arg_split[0]] = arg_split[1] + else: + override[arg_split[0]] = True + return override + + def get_additional_load_api_overrides(self): + """ + Return load_forecast_delta_api overrides by name. + """ + api_forecast_overrides = {} + api_overrides = self.api_select_update("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else [] + for api_command in api_overrides: + override = self.parse_additional_load_api_command(api_command) + if override: + api_forecast_overrides[str(override["name"])] = override + return api_forecast_overrides + + def refresh_additional_load_forecast_api(self): + """ + Rebuild additional load forecast data after the HA select API changes. + """ + self.load_forecast_delta_api = self.api_select_update("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else [] + self.house_load_additional_forecast_adjust, self.house_load_additional_forecasts = self.fetch_additional_load_forecast() + self.publish_additional_load_forecasts() + def get_additional_load_forecast_config(self): """ Return additional load forecast config with runtime API overrides applied by name. @@ -166,27 +208,8 @@ def get_additional_load_forecast_config(self): continue forecast_items.append(load_item.copy()) - api_forecast_overrides = {} - api_overrides = self.api_select_update("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else [] - for api_command in api_overrides: - if "?" not in api_command: - self.log("Warn: Bad load_forecast_delta_api command {}, expected name?start_time=...&duration=...".format(api_command)) - continue - name, command_args = api_command.split("?", 1) - if not name: - self.log("Warn: Bad load_forecast_delta_api command {}, missing name".format(api_command)) - continue - override = {"name": name} - for arg in command_args.split("&"): - arg_split = arg.split("=", 1) - if len(arg_split) > 1: - override[arg_split[0]] = arg_split[1] - else: - override[arg_split[0]] = True - api_forecast_overrides[str(name)] = override - runtime_overrides = {} - runtime_overrides.update(api_forecast_overrides) + runtime_overrides.update(self.get_additional_load_api_overrides()) runtime_overrides.update(self.house_load_additional_forecast_overrides) for name, override in runtime_overrides.items(): found = False diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 7aadfe6e2..6d7cc9553 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -213,6 +213,37 @@ def test_additional_load_select_api_weighting(my_predbat): return failed +def test_additional_load_select_event_updates_adjustment(my_predbat): + """Test HA select event immediately rebuilds additional load adjustment.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 0, "energy": 0}, + ] + + service_data = { + "domain": "select", + "service": "select_option", + "service_data": { + "entity_id": "select.predbat_load_forecast_delta_api", + "option": "dishwasher?start_time=18:00&duration=2.0&energy=1.2", + }, + } + run_async(my_predbat.trigger_callback(service_data)) + + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 18 * 60, 0.3, "select event immediate adjustment") + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 19 * 60 + 30, 0.3, "select event immediate adjustment") + sensor = my_predbat.dashboard_values.get("binary_sensor.predbat_load_forecast_delta_dishwasher", {}) + attributes = sensor.get("attributes", {}) + if sensor.get("state") != "on" or attributes.get("total_energy") != 1.2: + print("ERROR: Select event should immediately publish dishwasher forecast, got {}".format(sensor)) + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + def run_additional_load_forecast_tests(my_predbat): """Run additional load forecast tests.""" failed = 0 @@ -225,4 +256,5 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_multiple_and_service_override(my_predbat) failed |= test_additional_load_select_api_override(my_predbat) failed |= test_additional_load_select_api_weighting(my_predbat) + failed |= test_additional_load_select_event_updates_adjustment(my_predbat) return failed diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 54cc68576..703bf9ef7 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -309,8 +309,9 @@ async def select_event(self, event, data, kwargs): if isinstance(entities, str): entities = [entities] - for entity_id in entities: - await self.components.select_event(entity_id, value) + if self.components: + for entity_id in entities: + await self.components.select_event(entity_id, value) for item in self.CONFIG_ITEMS: if ("entity" in item) and (item["entity"] in entities): @@ -331,6 +332,8 @@ async def select_event(self, event, data, kwargs): await self.async_manual_select(item["name"], value) elif item.get("api"): await self.async_api_select(item["name"], value) + if item["name"] == "load_forecast_delta_api": + await self.run_in_executor(self.refresh_additional_load_forecast_api) else: if item.get("value", None) != value: await self.async_expose_config(item["name"], value, event=True) From da74cc920e33cccffe3b1a8ef9a6bbb0ce152e4f Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Wed, 6 May 2026 19:47:28 +0200 Subject: [PATCH 05/25] Add switches for additional load forecasts --- apps/predbat/fetch.py | 35 ++++++++++++++++++ .../tests/test_additional_load_forecast.py | 37 +++++++++++++++++++ apps/predbat/userinterface.py | 17 ++++++++- docs/apps-yaml.md | 2 + docs/manual-api.md | 2 + 5 files changed, 92 insertions(+), 1 deletion(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 3020a4ce9..f5e370236 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -146,6 +146,32 @@ def additional_load_entity_name(self, name): safe_name = "unknown" return "binary_sensor.{}_load_forecast_delta_{}".format(self.prefix, safe_name) + def additional_load_switch_entity_name(self, name): + """ + Make the switch entity name for a named additional load forecast. + """ + return self.additional_load_entity_name(name).replace("binary_sensor.", "switch.", 1) + + def additional_load_name_from_entity(self, entity_id): + """ + Return additional load forecast name from a binary sensor or switch entity id. + """ + marker = "_load_forecast_delta_" + if entity_id and marker in entity_id: + return entity_id.split(marker, 1)[1] + return None + + def set_additional_load_enabled(self, name, enabled): + """ + Enable or disable a named additional load forecast using a runtime override. + """ + name = str(name) + if enabled: + self.house_load_additional_forecast_overrides.pop(name, None) + else: + self.house_load_additional_forecast_overrides[name] = {"name": name, "energy": 0, "duration": 0} + self.refresh_additional_load_forecast_api() + def parse_additional_load_api_command(self, api_command): """ Parse one load_forecast_delta_api command into a forecast override. @@ -335,6 +361,15 @@ def publish_additional_load_forecasts(self): "target_times": forecast.get("target_times", []), } self.dashboard_item(forecast["entity_id"], state=forecast.get("state", "off"), attributes=attributes) + self.dashboard_item( + self.additional_load_switch_entity_name(name), + state=forecast.get("state", "off"), + attributes={ + "friendly_name": "Predbat load forecast delta {} enabled".format(name), + "icon": "mdi:dishwasher", + "name": name, + }, + ) def filtered_today(self, time_data, resetmidnight=False, stamp=None): """ diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 6d7cc9553..8bdf9be56 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -244,6 +244,42 @@ def test_additional_load_select_event_updates_adjustment(my_predbat): return failed +def test_additional_load_switch_disables_and_enables(my_predbat): + """Test companion switch disables and re-enables a named additional load.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "energy": 1.2}, + ] + my_predbat.refresh_additional_load_forecast_api() + + switch = my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}) + if switch.get("state") != "on": + print("ERROR: Dishwasher companion switch should publish on, got {}".format(switch)) + failed = 1 + + service_data = { + "domain": "switch", + "service": "turn_off", + "service_data": {"entity_id": "switch.predbat_load_forecast_delta_dishwasher"}, + } + run_async(my_predbat.trigger_callback(service_data)) + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 20 * 60, 0.0, "switch disabled dishwasher") + if my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}).get("state") != "off": + print("ERROR: Dishwasher companion switch should publish off") + failed = 1 + + service_data["service"] = "turn_on" + run_async(my_predbat.trigger_callback(service_data)) + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 20 * 60, 0.3, "switch enabled dishwasher") + if my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}).get("state") != "on": + print("ERROR: Dishwasher companion switch should publish on after re-enable") + failed = 1 + + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + def run_additional_load_forecast_tests(my_predbat): """Run additional load forecast tests.""" failed = 0 @@ -257,4 +293,5 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_select_api_override(my_predbat) failed |= test_additional_load_select_api_weighting(my_predbat) failed |= test_additional_load_select_event_updates_adjustment(my_predbat) + failed |= test_additional_load_switch_disables_and_enables(my_predbat) return failed diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 703bf9ef7..68bc6d24a 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -409,8 +409,23 @@ async def switch_event(self, event, data, kwargs): if isinstance(entities, str): entities = [entities] + if self.components: + for entity_id in entities: + await self.components.switch_event(entity_id, service) + for entity_id in entities: - await self.components.switch_event(entity_id, service) + if entity_id.startswith("switch.{}_load_forecast_delta_".format(self.prefix)): + name = self.additional_load_name_from_entity(entity_id) + if name: + if service == "turn_on": + self.set_additional_load_enabled(name, True) + elif service == "turn_off": + self.set_additional_load_enabled(name, False) + elif service == "toggle": + forecast = self.house_load_additional_forecasts.get(name, {}) + self.set_additional_load_enabled(name, forecast.get("state", "off") != "on") + self.update_pending = True + self.plan_valid = False for item in self.CONFIG_ITEMS: if ("entity" in item) and (item["entity"] in entities): diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 51f25f1da..c7ac41c2f 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1698,6 +1698,8 @@ With a 30-minute plan interval this redistributes 1.2kWh as 0.4kWh, 0.4kWh, 0.2k Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. The sensor attributes also show **energy**, **slot_energy**, **load_mode**, **plan_interval_minutes**, **slots**, and **total_energy** so you can confirm how much load will be added. +Predbat also publishes a companion switch for each named load, for example **switch.predbat_load_forecast_delta_dishwasher**. Turn this switch off to temporarily disable that forecast if you no longer want the scheduled load included in the plan; turn it back on to re-enable the configured or API-supplied forecast. + To update a named load from a Home Assistant automation, call **select.select_option** on **select.predbat_load_forecast_delta_api** with the format `name?start_time=HH:MM&duration=hours&energy=kWh`: ```yaml diff --git a/docs/manual-api.md b/docs/manual-api.md index 43893def2..1a69313b8 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -172,6 +172,8 @@ data: The **energy** value is the total kWh across the full duration. Predbat divides it across the generated plan slots. +To cancel a scheduled named load, turn off its companion switch, for example **switch.predbat_load_forecast_delta_dishwasher**. Turning the switch back on re-enables the configured or API-supplied forecast. + For advanced cases, you can use **slot_energy** instead when you want to set kWh per Predbat plan slot directly. With the default 30-minute plan interval, `slot_energy: 0.5` adds 0.5kWh to each slot for two hours. You can also include **weighting** to model a higher load at the start of a cycle: From e0f3097ff83ed8d4163893732524d3faaeff373e Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Wed, 6 May 2026 20:02:33 +0200 Subject: [PATCH 06/25] Add flexible additional load forecasts --- apps/predbat/fetch.py | 168 ++++++++++++++++-- .../tests/test_additional_load_forecast.py | 116 ++++++++++++ apps/predbat/userinterface.py | 3 +- docs/apps-yaml.md | 49 ++++- docs/manual-api.md | 12 ++ 5 files changed, 328 insertions(+), 20 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index f5e370236..9a462f811 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -137,6 +137,16 @@ def get_additional_load_float(self, load_item, key, default=0.0): self.record_status("Warn: Bad {} {} provided in house_load_additional_forecast".format(key, value), had_errors=True) return float(default) + def get_additional_load_bool(self, load_item, key, default=True): + """ + Resolve a boolean field on an additional load forecast item. + """ + value = load_item.get(key, default) + value = self.resolve_arg(key, value, default) + if isinstance(value, str): + return value.lower() in ["on", "true", "yes", "enable", "enabled", "1"] + return bool(value) + def additional_load_entity_name(self, name): """ Make the binary sensor entity name for a named additional load forecast. @@ -167,11 +177,90 @@ def set_additional_load_enabled(self, name, enabled): """ name = str(name) if enabled: - self.house_load_additional_forecast_overrides.pop(name, None) + self.house_load_additional_forecast_overrides[name] = {"name": name, "enabled": True} else: - self.house_load_additional_forecast_overrides[name] = {"name": name, "energy": 0, "duration": 0} + self.house_load_additional_forecast_overrides[name] = {"name": name, "enabled": False} self.refresh_additional_load_forecast_api() + def get_additional_load_window(self, load_item, mode, duration, plan_interval, minutes_now_slot): + """ + Return start/end minutes for fixed or flexible additional load scheduling. + """ + start_minutes = self.get_additional_load_time_minutes(load_item, "start_time") if "start_time" in load_item else None + end_minutes = self.get_additional_load_time_minutes(load_item, "end_time") if "end_time" in load_item else None + duration_minutes = int(duration * 60) + + if mode == "flexible": + if start_minutes is None and end_minutes is None: + return minutes_now_slot, minutes_now_slot + self.forecast_minutes + if start_minutes is None: + start_minutes = minutes_now_slot + if end_minutes is None: + end_minutes = start_minutes + self.forecast_minutes + + windows = [] + for day_offset in [0, 24 * 60]: + window_start = start_minutes + day_offset + window_end = end_minutes + day_offset + if window_end <= window_start: + window_end += 24 * 60 + windows.append((window_start, window_end)) + if end_minutes <= start_minutes: + windows.append((window_start - 24 * 60, window_end - 24 * 60)) + + for window_start, window_end in sorted(windows): + usable_start = max(window_start, minutes_now_slot) + if usable_start + duration_minutes <= window_end and usable_start < minutes_now_slot + self.forecast_minutes: + return usable_start, min(window_end, minutes_now_slot + self.forecast_minutes) + return None, None + + if start_minutes is None: + return None, end_minutes + if end_minutes is None: + end_minutes = start_minutes + int(duration * 60) + elif end_minutes <= start_minutes: + end_minutes += 24 * 60 + + if end_minutes <= minutes_now_slot: + start_minutes += 24 * 60 + end_minutes += 24 * 60 + return start_minutes, end_minutes + + def additional_load_slot_cost(self, slot_start, slot_end): + """ + Return approximate cost for using energy in one candidate slot. + """ + total = 0.0 + count = 0 + for minute in range(slot_start, slot_end, PREDICT_STEP): + total += self.rate_import.get(minute, self.rate_import.get(minute % (24 * 60), 0.0)) + count += 1 + return total / count if count else 0.0 + + def find_additional_load_flexible_start(self, start_minutes, end_minutes, duration, weights, plan_interval, minutes_now_slot): + """ + Find the lowest-cost start time for a flexible additional load. + """ + duration_minutes = int(duration * 60) + latest_start = end_minutes - duration_minutes + best_start = None + best_cost = None + candidate = max(start_minutes, minutes_now_slot) + candidate = int((candidate + plan_interval - 1) / plan_interval) * plan_interval + + while candidate <= latest_start and (candidate - minutes_now_slot) < self.forecast_minutes: + total_cost = 0.0 + for period, weight in enumerate(weights): + slot_start = candidate + period * plan_interval + slot_end = min(slot_start + plan_interval, candidate + duration_minutes) + total_cost += self.additional_load_slot_cost(slot_start, slot_end) * weight + if best_cost is None or total_cost < best_cost: + best_cost = total_cost + best_start = candidate + candidate += plan_interval + + return best_start, best_cost + def parse_additional_load_api_command(self, api_command): """ Parse one load_forecast_delta_api command into a forecast override. @@ -264,20 +353,35 @@ def fetch_additional_load_forecast(self): continue name = str(name) entity_id = self.additional_load_entity_name(name) - start_minutes = self.get_additional_load_time_minutes(load_item, "start_time") - end_minutes = self.get_additional_load_time_minutes(load_item, "end_time") if "end_time" in load_item else None + mode = str(self.resolve_arg("mode", load_item.get("mode", "fixed"), "fixed")).lower() + if mode not in ["fixed", "flexible"]: + self.log("Warn: Bad mode {} provided in house_load_additional_forecast {}, using fixed".format(mode, name)) + mode = "fixed" + enabled = self.get_additional_load_bool(load_item, "enabled", True) + duration_configured = "duration" in load_item duration = self.get_additional_load_float(load_item, "duration", 0.0) slot_energy = self.get_additional_load_float(load_item, "slot_energy", 0.0) energy_total = self.get_additional_load_float(load_item, "energy", 0.0) if "energy" in load_item else None weighting = self.resolve_arg("weighting", load_item.get("weighting", None), None) target_times = [] load_mode = "total_energy" if energy_total is not None else "slot_energy" + total_energy = 0.0 + start_minutes, end_minutes = self.get_additional_load_window(load_item, mode, duration, plan_interval, minutes_now_slot) + requested_start_minutes = start_minutes + requested_end_minutes = end_minutes + if mode == "fixed" and duration <= 0 and not duration_configured and start_minutes is not None and end_minutes is not None: + duration = (end_minutes - start_minutes) / 60.0 + periods = int((int(duration * 60) + plan_interval - 1) / plan_interval) if duration > 0 else 0 + weights = self.parse_additional_load_weighting(weighting, periods) + weight_total = sum(weights) - if start_minutes is None or (energy_total is None and slot_energy == 0) or (energy_total == 0) or duration == 0 and end_minutes is None: + if not enabled or start_minutes is None or (energy_total is None and slot_energy == 0) or (energy_total == 0) or duration == 0 or end_minutes is None: forecasts[name] = { "entity_id": entity_id, "state": "off", "target_times": target_times, + "enabled": enabled, + "mode": mode, "energy": energy_total, "slot_energy": slot_energy, "duration": duration, @@ -286,22 +390,38 @@ def fetch_additional_load_forecast(self): "plan_interval_minutes": plan_interval, "slots": 0, "total_energy": 0.0, + "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, + "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, + "suggested_start": None, + "suggested_end": None, } continue - if end_minutes is None: + if mode == "flexible": + suggested_start_minutes, _ = self.find_additional_load_flexible_start(start_minutes, end_minutes, duration, weights, plan_interval, minutes_now_slot) + if suggested_start_minutes is None: + forecasts[name] = { + "entity_id": entity_id, + "state": "off", + "target_times": target_times, + "enabled": enabled, + "mode": mode, + "energy": energy_total, + "slot_energy": slot_energy, + "duration": duration, + "weighting": weighting, + "load_mode": load_mode, + "plan_interval_minutes": plan_interval, + "slots": 0, + "total_energy": 0.0, + "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, + "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, + "suggested_start": None, + "suggested_end": None, + } + continue + start_minutes = suggested_start_minutes end_minutes = start_minutes + int(duration * 60) - elif end_minutes <= start_minutes: - end_minutes += 24 * 60 - - if end_minutes <= minutes_now_slot: - start_minutes += 24 * 60 - end_minutes += 24 * 60 - - periods = int((end_minutes - start_minutes + plan_interval - 1) / plan_interval) - weights = self.parse_additional_load_weighting(weighting, periods) - weight_total = sum(weights) - total_energy = 0.0 for period in range(periods): slot_start = start_minutes + period * plan_interval @@ -329,6 +449,8 @@ def fetch_additional_load_forecast(self): "entity_id": entity_id, "state": "on" if target_times else "off", "target_times": target_times, + "enabled": enabled, + "mode": mode, "energy": energy_total, "slot_energy": slot_energy, "duration": duration, @@ -337,6 +459,10 @@ def fetch_additional_load_forecast(self): "plan_interval_minutes": plan_interval, "slots": len(target_times), "total_energy": dp4(total_energy), + "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, + "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, + "suggested_start": (self.midnight_utc + timedelta(minutes=start_minutes)).isoformat() if mode == "flexible" and target_times else None, + "suggested_end": (self.midnight_utc + timedelta(minutes=end_minutes)).isoformat() if mode == "flexible" and target_times else None, } return load_adjust, forecasts @@ -350,6 +476,8 @@ def publish_additional_load_forecasts(self): "friendly_name": "Predbat load forecast delta {}".format(name), "icon": "mdi:dishwasher", "name": name, + "enabled": forecast.get("enabled", False), + "mode": forecast.get("mode", "fixed"), "energy": forecast.get("energy", None), "slot_energy": forecast.get("slot_energy", 0.0), "duration": forecast.get("duration", 0.0), @@ -358,6 +486,10 @@ def publish_additional_load_forecasts(self): "plan_interval_minutes": forecast.get("plan_interval_minutes", self.plan_interval_minutes), "slots": forecast.get("slots", 0), "total_energy": forecast.get("total_energy", 0.0), + "requested_start": forecast.get("requested_start", None), + "requested_end": forecast.get("requested_end", None), + "suggested_start": forecast.get("suggested_start", None), + "suggested_end": forecast.get("suggested_end", None), "target_times": forecast.get("target_times", []), } self.dashboard_item(forecast["entity_id"], state=forecast.get("state", "off"), attributes=attributes) @@ -368,6 +500,8 @@ def publish_additional_load_forecasts(self): "friendly_name": "Predbat load forecast delta {} enabled".format(name), "icon": "mdi:dishwasher", "name": name, + "enabled": forecast.get("enabled", False), + "mode": forecast.get("mode", "fixed"), }, ) diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 8bdf9be56..292fa3340 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -22,6 +22,13 @@ def configure_additional_load_test(my_predbat): my_predbat.house_load_additional_forecast_overrides = {} +def configure_additional_load_rates(my_predbat, cheap_start, cheap_end): + """Configure deterministic import rates for flexible load tests.""" + my_predbat.rate_import = {minute: 30.0 for minute in range(0, 3 * 24 * 60)} + for minute in range(cheap_start, cheap_end): + my_predbat.rate_import[minute] = 5.0 + + def check_slot(load_adjust, minute, expected, label): """Check one generated load adjustment minute value.""" actual = load_adjust.get(minute, 0.0) @@ -49,6 +56,25 @@ def test_additional_load_disabled(my_predbat): return failed +def test_additional_load_enabled_false_profile(my_predbat): + """Test enabled false publishes a disabled profile without load adjustment.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "enabled": False, "start_time": "20:00", "duration": 2.0, "energy": 1.2}, + ] + + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + if load_adjust: + print("ERROR: enabled false profile should not produce adjustments, got {}".format(load_adjust)) + failed = 1 + forecast = forecasts.get("dishwasher", {}) + if forecast.get("state") != "off" or forecast.get("enabled"): + print("ERROR: enabled false profile should publish off and enabled false, got {}".format(forecast)) + failed = 1 + return failed + + def test_additional_load_dishwasher_simple(my_predbat): """Test a simple dishwasher total energy forecast.""" failed = 0 @@ -79,6 +105,24 @@ def test_additional_load_dishwasher_simple(my_predbat): return failed +def test_additional_load_end_time_without_duration(my_predbat): + """Test fixed additional load can use end_time instead of duration.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "cooking", "start_time": "18:00", "end_time": "19:30", "energy": 1.2}, + ] + + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 18 * 60, 0.4, "end time without duration") + failed |= check_slot(load_adjust, 18 * 60 + 30, 0.4, "end time without duration") + failed |= check_slot(load_adjust, 19 * 60, 0.4, "end time without duration") + if forecasts.get("cooking", {}).get("slots") != 3: + print("ERROR: end_time without duration should create 3 slots") + failed = 1 + return failed + + def test_additional_load_slot_energy_weighting(my_predbat): """Test advanced slot energy weighting multiplies the per-slot energy.""" failed = 0 @@ -280,12 +324,81 @@ def test_additional_load_switch_disables_and_enables(my_predbat): return failed +def test_additional_load_switch_enables_disabled_profile(my_predbat): + """Test companion switch can enable a disabled configured profile.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "enabled": False, "start_time": "20:00", "duration": 2.0, "energy": 1.2}, + ] + my_predbat.refresh_additional_load_forecast_api() + + if my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}).get("state") != "off": + print("ERROR: Disabled profile companion switch should publish off") + failed = 1 + + service_data = { + "domain": "switch", + "service": "turn_on", + "service_data": {"entity_id": "switch.predbat_load_forecast_delta_dishwasher"}, + } + run_async(my_predbat.trigger_callback(service_data)) + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 20 * 60, 0.3, "switch enabled disabled profile") + if my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}).get("state") != "on": + print("ERROR: Disabled profile switch should publish on after enable") + failed = 1 + + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + +def test_additional_load_flexible_cheapest_slot(my_predbat): + """Test flexible additional load is placed in the cheapest available block.""" + failed = 0 + configure_additional_load_test(my_predbat) + configure_additional_load_rates(my_predbat, 18 * 60, 20 * 60) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "mode": "flexible", "duration": 2.0, "energy": 1.2}, + ] + + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 18 * 60, 0.3, "flexible cheapest slot") + failed |= check_slot(load_adjust, 19 * 60 + 30, 0.3, "flexible cheapest slot") + forecast = forecasts.get("dishwasher", {}) + if forecast.get("mode") != "flexible" or "T18:00:00" not in forecast.get("suggested_start", ""): + print("ERROR: Flexible load should suggest 18:00, got {}".format(forecast)) + failed = 1 + return failed + + +def test_additional_load_flexible_overnight_window(my_predbat): + """Test flexible additional load supports an overnight allowed window.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.minutes_now = 23 * 60 + configure_additional_load_rates(my_predbat, 25 * 60, 27 * 60) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "mode": "flexible", "start_time": "22:00", "end_time": "07:00", "duration": 2.0, "energy": 1.2}, + ] + + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 25 * 60, 0.3, "flexible overnight window") + failed |= check_slot(load_adjust, 26 * 60 + 30, 0.3, "flexible overnight window") + forecast = forecasts.get("dishwasher", {}) + if "T01:00:00" not in forecast.get("suggested_start", ""): + print("ERROR: Flexible overnight load should suggest 01:00, got {}".format(forecast)) + failed = 1 + return failed + + def run_additional_load_forecast_tests(my_predbat): """Run additional load forecast tests.""" failed = 0 print("Test additional load forecast") failed |= test_additional_load_disabled(my_predbat) + failed |= test_additional_load_enabled_false_profile(my_predbat) failed |= test_additional_load_dishwasher_simple(my_predbat) + failed |= test_additional_load_end_time_without_duration(my_predbat) failed |= test_additional_load_slot_energy_weighting(my_predbat) failed |= test_additional_load_dishwasher_total_energy(my_predbat) failed |= test_additional_load_dishwasher_total_energy_weighting(my_predbat) @@ -294,4 +407,7 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_select_api_weighting(my_predbat) failed |= test_additional_load_select_event_updates_adjustment(my_predbat) failed |= test_additional_load_switch_disables_and_enables(my_predbat) + failed |= test_additional_load_switch_enables_disabled_profile(my_predbat) + failed |= test_additional_load_flexible_cheapest_slot(my_predbat) + failed |= test_additional_load_flexible_overnight_window(my_predbat) return failed diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 68bc6d24a..c876c6d4d 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -896,10 +896,11 @@ async def load_forecast_delta_event(self, service, data, kwargs): return forecast = {"name": str(name)} - for key in ["start_time", "end_time", "duration", "energy", "slot_energy", "weighting"]: + for key in ["start_time", "end_time", "duration", "energy", "slot_energy", "weighting", "enabled", "mode"]: if key in service_data: forecast[key] = service_data[key] self.house_load_additional_forecast_overrides[str(name)] = forecast + await self.run_in_executor(self.refresh_additional_load_forecast_api) self.plan_valid = False self.update_pending = True self.log("Updated additional load forecast {} via service {}".format(name, service)) diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index c7ac41c2f..b5509d715 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1658,7 +1658,18 @@ For advanced cases, use **slot_energy** when you want to set the kWh for each Pr The **slot_energy** value is kWh per Predbat plan slot, not the total energy for the full duration. With the default 30-minute plan interval, the example above adds 0.5kWh to each of the four slots from 20:00 to 22:00, for a total of 2.0kWh. With a 15-minute plan interval it would add 0.5kWh to each of eight slots, for a total of 4.0kWh. -Set **duration** to `0` to leave a named load configured but disabled by default. +Set **enabled** to `false` to leave a named load configured but disabled by default. Predbat will still publish the binary sensor and companion switch, but it will not add any load to the plan until the switch is turned on or the API sends `enabled=true`. + +```yaml + house_load_additional_forecast: + - name: dishwasher_eco + enabled: false + start_time: "20:00" + duration: 2.0 + energy: 1.2 +``` + +Set **duration** to `0` to disable an entry completely. You can optionally set **end_time** instead of **duration**: @@ -1696,7 +1707,31 @@ Using total **energy** with weighting: With a 30-minute plan interval this redistributes 1.2kWh as 0.4kWh, 0.4kWh, 0.2kWh, and 0.2kWh. -Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. The sensor attributes also show **energy**, **slot_energy**, **load_mode**, **plan_interval_minutes**, **slots**, and **total_energy** so you can confirm how much load will be added. +Set **mode** to `flexible` when the load can run at any time within an allowed window. Predbat will place the duration into the cheapest block it can find using the current import rates. If **start_time** and **end_time** are omitted, the allowed window is the remaining forecast horizon. + +```yaml + house_load_additional_forecast: + - name: dishwasher_eco + enabled: false + mode: flexible + duration: 2.0 + energy: 1.2 +``` + +You can also restrict a flexible load to a same-day or overnight window: + +```yaml + house_load_additional_forecast: + - name: dishwasher_eco + enabled: false + mode: flexible + start_time: "22:00" + end_time: "07:00" + duration: 2.0 + energy: 1.2 +``` + +Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. The sensor attributes also show **enabled**, **mode**, **energy**, **slot_energy**, **load_mode**, **plan_interval_minutes**, **slots**, **total_energy**, and for flexible loads **requested_start**, **requested_end**, **suggested_start**, and **suggested_end** so you can confirm how much load will be added and when. Predbat also publishes a companion switch for each named load, for example **switch.predbat_load_forecast_delta_dishwasher**. Turn this switch off to temporarily disable that forecast if you no longer want the scheduled load included in the plan; turn it back on to re-enable the configured or API-supplied forecast. @@ -1710,6 +1745,16 @@ data: option: "dishwasher?start_time=20:00&duration=2.0&energy=1.2" ``` +For a flexible API update, include `mode=flexible` and optionally `enabled=true`: + +```yaml +action: select.select_option +target: + entity_id: select.predbat_load_forecast_delta_api +data: + option: "dishwasher_eco?enabled=true&mode=flexible&start_time=22:00&end_time=07:00&duration=2.0&energy=1.2" +``` + ## Balance Inverters When you have two or more inverters it's possible they get out of sync so they are at different charge levels or they start to cross-charge (one discharges into another). diff --git a/docs/manual-api.md b/docs/manual-api.md index 1a69313b8..3c076acf3 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -174,6 +174,18 @@ The **energy** value is the total kWh across the full duration. Predbat divides To cancel a scheduled named load, turn off its companion switch, for example **switch.predbat_load_forecast_delta_dishwasher**. Turning the switch back on re-enables the configured or API-supplied forecast. +If the appliance can run at any time, send `mode=flexible`. Predbat will choose the cheapest available block from the requested window, or from the remaining forecast horizon if no window is supplied: + +```yaml +action: select.select_option +target: + entity_id: select.predbat_load_forecast_delta_api +data: + option: "dishwasher?enabled=true&mode=flexible&start_time=22:00&end_time=07:00&duration=2.0&energy=1.2" +``` + +Use `enabled=false` in `apps.yaml` to keep reusable appliance profiles visible but inactive until an automation or the companion switch enables them. + For advanced cases, you can use **slot_energy** instead when you want to set kWh per Predbat plan slot directly. With the default 30-minute plan interval, `slot_energy: 0.5` adds 0.5kWh to each slot for two hours. You can also include **weighting** to model a higher load at the start of a cycle: From 099fb3515b94b8fc8bf4da2cc73d5556c1589d3c Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 07:33:23 +0200 Subject: [PATCH 07/25] Refine dynamic additional load forecasts --- apps/predbat/fetch.py | 225 +++++++++++------- apps/predbat/plan.py | 120 ++++++++++ .../tests/test_additional_load_forecast.py | 169 ++++++++----- apps/predbat/userinterface.py | 39 +-- docs/apps-yaml.md | 24 +- docs/manual-api.md | 16 +- 6 files changed, 430 insertions(+), 163 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 9a462f811..6f92a14a4 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -156,11 +156,11 @@ def additional_load_entity_name(self, name): safe_name = "unknown" return "binary_sensor.{}_load_forecast_delta_{}".format(self.prefix, safe_name) - def additional_load_switch_entity_name(self, name): + def additional_load_delete_entity_name(self, name): """ - Make the switch entity name for a named additional load forecast. + Make the delete button entity name for a named additional load forecast. """ - return self.additional_load_entity_name(name).replace("binary_sensor.", "switch.", 1) + return self.additional_load_entity_name(name).replace("binary_sensor.", "button.", 1) + "_delete" def additional_load_name_from_entity(self, entity_id): """ @@ -168,20 +168,38 @@ def additional_load_name_from_entity(self, entity_id): """ marker = "_load_forecast_delta_" if entity_id and marker in entity_id: - return entity_id.split(marker, 1)[1] + return entity_id.split(marker, 1)[1].replace("_delete", "") return None - def set_additional_load_enabled(self, name, enabled): + def delete_additional_load_forecast(self, name): """ - Enable or disable a named additional load forecast using a runtime override. + Delete a named one-shot additional load forecast. """ name = str(name) - if enabled: - self.house_load_additional_forecast_overrides[name] = {"name": name, "enabled": True} - else: - self.house_load_additional_forecast_overrides[name] = {"name": name, "enabled": False} + self.house_load_additional_forecast_overrides.pop(name, None) + self.remove_additional_load_api_command(name) self.refresh_additional_load_forecast_api() + def remove_additional_load_api_command(self, name): + """ + Remove a named forecast command from the load_forecast_delta_api selector. + """ + item = self.config_index.get("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else None + if not item: + return + values = item.get("value", "") or "" + values = values.replace("+", "") + values_list = values.split(",") if values else [] + new_values_list = [] + for value in values_list: + if value == "off": + continue + command_name = value.split("?", 1)[0].split("=", 1)[0].replace("[", "").replace("]", "") + if command_name != name: + new_values_list.append(value) + new_value = "+" + ",".join(new_values_list) if new_values_list else "off" + self.api_select_update("load_forecast_delta_api", new_value=new_value) + def get_additional_load_window(self, load_item, mode, duration, plan_interval, minutes_now_slot): """ Return start/end minutes for fixed or flexible additional load scheduling. @@ -226,41 +244,6 @@ def get_additional_load_window(self, load_item, mode, duration, plan_interval, m end_minutes += 24 * 60 return start_minutes, end_minutes - def additional_load_slot_cost(self, slot_start, slot_end): - """ - Return approximate cost for using energy in one candidate slot. - """ - total = 0.0 - count = 0 - for minute in range(slot_start, slot_end, PREDICT_STEP): - total += self.rate_import.get(minute, self.rate_import.get(minute % (24 * 60), 0.0)) - count += 1 - return total / count if count else 0.0 - - def find_additional_load_flexible_start(self, start_minutes, end_minutes, duration, weights, plan_interval, minutes_now_slot): - """ - Find the lowest-cost start time for a flexible additional load. - """ - duration_minutes = int(duration * 60) - latest_start = end_minutes - duration_minutes - best_start = None - best_cost = None - candidate = max(start_minutes, minutes_now_slot) - candidate = int((candidate + plan_interval - 1) / plan_interval) * plan_interval - - while candidate <= latest_start and (candidate - minutes_now_slot) < self.forecast_minutes: - total_cost = 0.0 - for period, weight in enumerate(weights): - slot_start = candidate + period * plan_interval - slot_end = min(slot_start + plan_interval, candidate + duration_minutes) - total_cost += self.additional_load_slot_cost(slot_start, slot_end) * weight - if best_cost is None or total_cost < best_cost: - best_cost = total_cost - best_start = candidate - candidate += plan_interval - - return best_start, best_cost - def parse_additional_load_api_command(self, api_command): """ Parse one load_forecast_delta_api command into a forecast override. @@ -274,7 +257,7 @@ def parse_additional_load_api_command(self, api_command): self.log("Warn: Bad load_forecast_delta_api command {}, missing name".format(api_command)) return None - override = {"name": name} + override = {"name": name, "_source": "api", "_auto_expire": True} for arg in command_args.split("&"): arg_split = arg.split("=", 1) if len(arg_split) > 1: @@ -283,6 +266,21 @@ def parse_additional_load_api_command(self, api_command): override[arg_split[0]] = True return override + def expire_additional_load_api_commands(self): + """ + Remove expired one-shot additional load forecast API commands. + """ + expired_names = [] + minutes_now_slot = int(self.minutes_now / self.get_arg("plan_interval_minutes", 30)) * self.get_arg("plan_interval_minutes", 30) + for name, override in list(self.house_load_additional_forecast_overrides.items()): + expires_minutes = override.get("_expires_minutes", None) + if expires_minutes is not None and expires_minutes <= minutes_now_slot: + expired_names.append(name) + for name in expired_names: + self.log("Expired additional load forecast {}".format(name)) + self.house_load_additional_forecast_overrides.pop(name, None) + self.remove_additional_load_api_command(name) + def get_additional_load_api_overrides(self): """ Return load_forecast_delta_api overrides by name. @@ -321,8 +319,12 @@ def get_additional_load_forecast_config(self): if not isinstance(load_item, dict): self.log("Warn: Bad house_load_additional_forecast item {}, expected dictionary".format(load_item)) continue - forecast_items.append(load_item.copy()) + load_item = load_item.copy() + load_item["_source"] = "yaml" + load_item["_auto_expire"] = False + forecast_items.append(load_item) + self.expire_additional_load_api_commands() runtime_overrides = {} runtime_overrides.update(self.get_additional_load_api_overrides()) runtime_overrides.update(self.house_load_additional_forecast_overrides) @@ -337,10 +339,12 @@ def get_additional_load_forecast_config(self): forecast_items.append(override.copy()) return forecast_items - def fetch_additional_load_forecast(self): + def fetch_additional_load_forecast(self, selected_flexible=None): """ Build per-minute additional load adjustments from named forecast config. """ + if selected_flexible is None: + selected_flexible = {} load_adjust = {} forecasts = {} plan_interval = self.get_arg("plan_interval_minutes", 30) @@ -357,12 +361,17 @@ def fetch_additional_load_forecast(self): if mode not in ["fixed", "flexible"]: self.log("Warn: Bad mode {} provided in house_load_additional_forecast {}, using fixed".format(mode, name)) mode = "fixed" + if mode == "flexible" and name in selected_flexible: + load_item.update(selected_flexible[name]) enabled = self.get_additional_load_bool(load_item, "enabled", True) duration_configured = "duration" in load_item duration = self.get_additional_load_float(load_item, "duration", 0.0) slot_energy = self.get_additional_load_float(load_item, "slot_energy", 0.0) energy_total = self.get_additional_load_float(load_item, "energy", 0.0) if "energy" in load_item else None weighting = self.resolve_arg("weighting", load_item.get("weighting", None), None) + source = load_item.get("_source", "yaml") + auto_expire = load_item.get("_auto_expire", False) + expires_minutes = load_item.get("_expires_minutes", None) target_times = [] load_mode = "total_energy" if energy_total is not None else "slot_energy" total_energy = 0.0 @@ -375,6 +384,15 @@ def fetch_additional_load_forecast(self): weights = self.parse_additional_load_weighting(weighting, periods) weight_total = sum(weights) + selected_start_minutes = load_item.get("_selected_start_minutes", None) + if selected_start_minutes is not None: + start_minutes = int(selected_start_minutes) + end_minutes = start_minutes + int(duration * 60) + if auto_expire and expires_minutes is None and end_minutes is not None: + expires_minutes = end_minutes + if auto_expire and source != "yaml" and expires_minutes is not None: + self.house_load_additional_forecast_overrides.setdefault(name, {"name": name})["_expires_minutes"] = expires_minutes + if not enabled or start_minutes is None or (energy_total is None and slot_energy == 0) or (energy_total == 0) or duration == 0 or end_minutes is None: forecasts[name] = { "entity_id": entity_id, @@ -394,34 +412,54 @@ def fetch_additional_load_forecast(self): "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, "suggested_start": None, "suggested_end": None, + "selection_reason": None, + "candidate_count": 0, + "selected_metric": None, + "baseline_metric": None, + "source": source, + "auto_expire": auto_expire, + "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, + "_requested_start_minutes": requested_start_minutes, + "_requested_end_minutes": requested_end_minutes, + "_periods": periods, + "_weights": weights, + "_weight_total": weight_total, } continue - if mode == "flexible": - suggested_start_minutes, _ = self.find_additional_load_flexible_start(start_minutes, end_minutes, duration, weights, plan_interval, minutes_now_slot) - if suggested_start_minutes is None: - forecasts[name] = { - "entity_id": entity_id, - "state": "off", - "target_times": target_times, - "enabled": enabled, - "mode": mode, - "energy": energy_total, - "slot_energy": slot_energy, - "duration": duration, - "weighting": weighting, - "load_mode": load_mode, - "plan_interval_minutes": plan_interval, - "slots": 0, - "total_energy": 0.0, - "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, - "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, - "suggested_start": None, - "suggested_end": None, - } - continue - start_minutes = suggested_start_minutes - end_minutes = start_minutes + int(duration * 60) + if mode == "flexible" and selected_start_minutes is None: + forecasts[name] = { + "entity_id": entity_id, + "state": "off", + "target_times": target_times, + "enabled": enabled, + "mode": mode, + "energy": energy_total, + "slot_energy": slot_energy, + "duration": duration, + "weighting": weighting, + "load_mode": load_mode, + "plan_interval_minutes": plan_interval, + "slots": 0, + "total_energy": 0.0, + "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, + "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, + "suggested_start": None, + "suggested_end": None, + "selection_reason": "pending_prediction_metric", + "candidate_count": 0, + "selected_metric": None, + "baseline_metric": None, + "source": source, + "auto_expire": auto_expire, + "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, + "_requested_start_minutes": requested_start_minutes, + "_requested_end_minutes": requested_end_minutes, + "_periods": periods, + "_weights": weights, + "_weight_total": weight_total, + } + continue for period in range(periods): slot_start = start_minutes + period * plan_interval @@ -463,6 +501,18 @@ def fetch_additional_load_forecast(self): "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, "suggested_start": (self.midnight_utc + timedelta(minutes=start_minutes)).isoformat() if mode == "flexible" and target_times else None, "suggested_end": (self.midnight_utc + timedelta(minutes=end_minutes)).isoformat() if mode == "flexible" and target_times else None, + "selection_reason": load_item.get("_selection_reason", "prediction_metric" if mode == "flexible" and target_times else None), + "candidate_count": load_item.get("_candidate_count", 0), + "selected_metric": load_item.get("_selected_metric", None), + "baseline_metric": load_item.get("_baseline_metric", None), + "source": source, + "auto_expire": auto_expire, + "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, + "_requested_start_minutes": requested_start_minutes, + "_requested_end_minutes": requested_end_minutes, + "_periods": periods, + "_weights": weights, + "_weight_total": weight_total, } return load_adjust, forecasts @@ -490,20 +540,27 @@ def publish_additional_load_forecasts(self): "requested_end": forecast.get("requested_end", None), "suggested_start": forecast.get("suggested_start", None), "suggested_end": forecast.get("suggested_end", None), + "selection_reason": forecast.get("selection_reason", None), + "candidate_count": forecast.get("candidate_count", 0), + "selected_metric": forecast.get("selected_metric", None), + "baseline_metric": forecast.get("baseline_metric", None), + "source": forecast.get("source", "yaml"), + "auto_expire": forecast.get("auto_expire", False), + "expires_at": forecast.get("expires_at", None), "target_times": forecast.get("target_times", []), } self.dashboard_item(forecast["entity_id"], state=forecast.get("state", "off"), attributes=attributes) - self.dashboard_item( - self.additional_load_switch_entity_name(name), - state=forecast.get("state", "off"), - attributes={ - "friendly_name": "Predbat load forecast delta {} enabled".format(name), - "icon": "mdi:dishwasher", - "name": name, - "enabled": forecast.get("enabled", False), - "mode": forecast.get("mode", "fixed"), - }, - ) + if forecast.get("source", "yaml") != "yaml": + self.dashboard_item( + self.additional_load_delete_entity_name(name), + state="idle", + attributes={ + "friendly_name": "Delete Predbat load forecast delta {}".format(name), + "icon": "mdi:delete", + "name": name, + "source": forecast.get("source", "api"), + }, + ) def filtered_today(self, time_data, resetmidnight=False, stamp=None): """ diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 13a607513..1be51efc4 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -67,6 +67,120 @@ class Plan: runs to minimise the overall cost metric. """ + def additional_load_candidate_profile(self, forecast, start_minutes): + """ + Build absolute-minute adjustment and target metadata for one flexible load candidate. + """ + plan_interval = forecast.get("plan_interval_minutes", self.plan_interval_minutes) + duration_minutes = int(forecast.get("duration", 0.0) * 60) + end_minutes = start_minutes + duration_minutes + periods = forecast.get("_periods", 0) + weights = forecast.get("_weights", []) + weight_total = forecast.get("_weight_total", sum(weights)) + energy_total = forecast.get("energy", None) + slot_energy = forecast.get("slot_energy", 0.0) + load_adjust = {} + target_times = [] + total_energy = 0.0 + + for period in range(periods): + slot_start = start_minutes + period * plan_interval + slot_end = min(slot_start + plan_interval, end_minutes) + if slot_end <= self.minutes_now: + continue + if (slot_start - self.minutes_now) >= self.forecast_minutes: + continue + if energy_total is not None: + energy = dp4(energy_total * weights[period] / weight_total) if weight_total else 0.0 + else: + energy = dp4(slot_energy * weights[period]) + total_energy += energy + for minute in range(slot_start, slot_end): + load_adjust[minute] = dp4(load_adjust.get(minute, 0.0) + energy) + target_times.append({"start": (self.midnight_utc + timedelta(minutes=slot_start)).isoformat(), "end": (self.midnight_utc + timedelta(minutes=slot_end)).isoformat(), "energy": energy}) + + return load_adjust, target_times, dp4(total_energy) + + def add_additional_load_to_step_data(self, load_minutes_step, load_adjust): + """ + Add absolute-minute additional load adjustment into prediction step data. + """ + modified_load = copy.deepcopy(load_minutes_step) + for minute_absolute, energy in load_adjust.items(): + minute_relative = minute_absolute - self.minutes_now + if minute_relative < 0 or minute_relative >= self.forecast_minutes: + continue + step_minute = int(minute_relative / PREDICT_STEP) * PREDICT_STEP + modified_load[step_minute] = dp4(modified_load.get(step_minute, 0.0) + energy * PREDICT_STEP / float(self.plan_interval_minutes)) + return modified_load + + def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step10, pv_forecast_minute_step, pv_forecast_minute10_step): + """ + Select flexible additional load start times using full prediction metric impact. + """ + flexible_forecasts = {name: forecast for name, forecast in self.house_load_additional_forecasts.items() if forecast.get("enabled") and forecast.get("mode") == "flexible" and not forecast.get("target_times")} + if not flexible_forecasts: + return False, load_minutes_step, load_minutes_step10 + + selected_flexible = {} + working_load_step = load_minutes_step + working_load_step10 = load_minutes_step10 + + for name, forecast in flexible_forecasts.items(): + start_minutes = forecast.get("_requested_start_minutes", None) + end_minutes = forecast.get("_requested_end_minutes", None) + duration_minutes = int(forecast.get("duration", 0.0) * 60) + plan_interval = forecast.get("plan_interval_minutes", self.plan_interval_minutes) + if start_minutes is None or end_minutes is None or duration_minutes <= 0: + continue + + candidate = max(start_minutes, self.minutes_now) + candidate = int((candidate + plan_interval - 1) / plan_interval) * plan_interval + latest_start = min(end_minutes - duration_minutes, self.minutes_now + self.forecast_minutes - duration_minutes) + if latest_start < candidate: + continue + + baseline_prediction = Prediction(self, pv_forecast_minute_step, pv_forecast_minute10_step, working_load_step, working_load_step10) + baseline_metric = baseline_prediction.run_prediction(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, False, self.end_record)[0] + best_start = None + best_metric = None + candidate_count = 0 + + while candidate <= latest_start: + candidate_adjust, _, _ = self.additional_load_candidate_profile(forecast, candidate) + candidate_load_step = self.add_additional_load_to_step_data(working_load_step, candidate_adjust) + candidate_load_step10 = self.add_additional_load_to_step_data(working_load_step10, candidate_adjust) + candidate_prediction = Prediction(self, pv_forecast_minute_step, pv_forecast_minute10_step, candidate_load_step, candidate_load_step10) + candidate_metric = candidate_prediction.run_prediction(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, False, self.end_record)[0] + candidate_count += 1 + if best_metric is None or candidate_metric < best_metric: + best_metric = candidate_metric + best_start = candidate + candidate += plan_interval + + if best_start is not None: + best_adjust, _, _ = self.additional_load_candidate_profile(forecast, best_start) + working_load_step = self.add_additional_load_to_step_data(working_load_step, best_adjust) + working_load_step10 = self.add_additional_load_to_step_data(working_load_step10, best_adjust) + selected_flexible[name] = { + "_selected_start_minutes": best_start, + "_selection_reason": "prediction_metric", + "_candidate_count": candidate_count, + "_selected_metric": dp2(best_metric) if best_metric is not None else None, + "_baseline_metric": dp2(baseline_metric), + "_expires_minutes": best_start + duration_minutes if forecast.get("auto_expire", False) else None, + } + if forecast.get("auto_expire", False): + self.house_load_additional_forecast_overrides[name] = {"name": name, "_expires_minutes": best_start + duration_minutes} + self.log("Flexible additional load {} selected {}-{} using prediction metric {} from {} candidates".format(name, self.time_abs_str(best_start), self.time_abs_str(best_start + duration_minutes), dp2(best_metric), candidate_count)) + + if not selected_flexible: + return False, load_minutes_step, load_minutes_step10 + + self.house_load_additional_forecast_adjust, self.house_load_additional_forecasts = self.fetch_additional_load_forecast(selected_flexible=selected_flexible) + self.publish_additional_load_forecasts() + return True, working_load_step, working_load_step10 + def dynamic_load(self): """ Adjust load prediction based on current load @@ -975,6 +1089,12 @@ def calculate_plan(self, recompute=True, debug_mode=False, publish=True): # Creation prediction object self.prediction = Prediction(self, pv_forecast_minute_step, pv_forecast_minute10_step, load_minutes_step, load_minutes_step10) + 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) + # Check if LoadML is active and disable thread pools as it causes lockup due to race conditions with NumPy load_ml_comp = self.components.get_component("load_ml") if self.components else None load_ml_calculating = False diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 292fa3340..ed4de1d8d 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -10,6 +10,7 @@ """Tests for named additional house load forecasts.""" +import plan as plan_module from tests.test_infra import run_async @@ -288,105 +289,159 @@ def test_additional_load_select_event_updates_adjustment(my_predbat): return failed -def test_additional_load_switch_disables_and_enables(my_predbat): - """Test companion switch disables and re-enables a named additional load.""" +def test_additional_load_delete_button_removes_api_forecast(my_predbat): + """Test delete button removes a one-shot API forecast.""" failed = 0 configure_additional_load_test(my_predbat) - my_predbat.args["house_load_additional_forecast"] = [ - {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "energy": 1.2}, - ] + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.api_select("load_forecast_delta_api", "dishwasher?start_time=20:00&duration=2.0&energy=1.2") my_predbat.refresh_additional_load_forecast_api() - switch = my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}) - if switch.get("state") != "on": - print("ERROR: Dishwasher companion switch should publish on, got {}".format(switch)) + button = my_predbat.dashboard_values.get("button.predbat_load_forecast_delta_dishwasher_delete", {}) + if button.get("state") != "idle": + print("ERROR: Dishwasher delete button should publish idle, got {}".format(button)) failed = 1 service_data = { - "domain": "switch", - "service": "turn_off", - "service_data": {"entity_id": "switch.predbat_load_forecast_delta_dishwasher"}, + "domain": "button", + "service": "press", + "service_data": {"entity_id": "button.predbat_load_forecast_delta_dishwasher_delete"}, } run_async(my_predbat.trigger_callback(service_data)) - failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 20 * 60, 0.0, "switch disabled dishwasher") - if my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}).get("state") != "off": - print("ERROR: Dishwasher companion switch should publish off") - failed = 1 - - service_data["service"] = "turn_on" - run_async(my_predbat.trigger_callback(service_data)) - failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 20 * 60, 0.3, "switch enabled dishwasher") - if my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}).get("state") != "on": - print("ERROR: Dishwasher companion switch should publish on after re-enable") + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 20 * 60, 0.0, "delete button removed dishwasher") + if my_predbat.api_select_update("load_forecast_delta_api"): + print("ERROR: Delete button should clear API forecast") failed = 1 my_predbat.house_load_additional_forecast_overrides = {} return failed -def test_additional_load_switch_enables_disabled_profile(my_predbat): - """Test companion switch can enable a disabled configured profile.""" +def test_additional_load_yaml_does_not_publish_delete_button(my_predbat): + """Test YAML forecasts do not get one-shot delete buttons.""" failed = 0 configure_additional_load_test(my_predbat) + my_predbat.dashboard_values.pop("button.predbat_load_forecast_delta_dishwasher_delete", None) my_predbat.args["house_load_additional_forecast"] = [ - {"name": "dishwasher", "enabled": False, "start_time": "20:00", "duration": 2.0, "energy": 1.2}, + {"name": "dishwasher", "start_time": "20:00", "duration": 2.0, "energy": 1.2}, ] my_predbat.refresh_additional_load_forecast_api() - if my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}).get("state") != "off": - print("ERROR: Disabled profile companion switch should publish off") + if "button.predbat_load_forecast_delta_dishwasher_delete" in my_predbat.dashboard_values: + print("ERROR: YAML forecast should not publish delete button") failed = 1 - service_data = { - "domain": "switch", - "service": "turn_on", - "service_data": {"entity_id": "switch.predbat_load_forecast_delta_dishwasher"}, - } - run_async(my_predbat.trigger_callback(service_data)) - failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 20 * 60, 0.3, "switch enabled disabled profile") - if my_predbat.dashboard_values.get("switch.predbat_load_forecast_delta_dishwasher", {}).get("state") != "on": - print("ERROR: Disabled profile switch should publish on after enable") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + +def test_additional_load_api_forecast_auto_expires(my_predbat): + """Test one-shot API forecasts are removed after their finish time.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.api_select("load_forecast_delta_api", "dishwasher?start_time=18:00&duration=1.0&energy=0.8") + my_predbat.refresh_additional_load_forecast_api() + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 18 * 60, 0.4, "api forecast before expiry") + + my_predbat.minutes_now = 19 * 60 + my_predbat.refresh_additional_load_forecast_api() + if my_predbat.api_select_update("load_forecast_delta_api"): + print("ERROR: API forecast should be removed after expiry") + failed = 1 + if my_predbat.house_load_additional_forecasts: + print("ERROR: Expired API forecast should not remain active, got {}".format(my_predbat.house_load_additional_forecasts)) failed = 1 my_predbat.house_load_additional_forecast_overrides = {} return failed -def test_additional_load_flexible_cheapest_slot(my_predbat): - """Test flexible additional load is placed in the cheapest available block.""" +def test_additional_load_flexible_pending_until_plan(my_predbat): + """Test flexible additional load is left for plan-time prediction selection.""" failed = 0 configure_additional_load_test(my_predbat) - configure_additional_load_rates(my_predbat, 18 * 60, 20 * 60) my_predbat.args["house_load_additional_forecast"] = [ {"name": "dishwasher", "mode": "flexible", "duration": 2.0, "energy": 1.2}, ] load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() - failed |= check_slot(load_adjust, 18 * 60, 0.3, "flexible cheapest slot") - failed |= check_slot(load_adjust, 19 * 60 + 30, 0.3, "flexible cheapest slot") + if load_adjust: + print("ERROR: Flexible load should not produce adjustments until plan-time selection, got {}".format(load_adjust)) + failed = 1 forecast = forecasts.get("dishwasher", {}) - if forecast.get("mode") != "flexible" or "T18:00:00" not in forecast.get("suggested_start", ""): - print("ERROR: Flexible load should suggest 18:00, got {}".format(forecast)) + if forecast.get("state") != "off" or forecast.get("selection_reason") != "pending_prediction_metric": + print("ERROR: Flexible load should publish pending prediction selection, got {}".format(forecast)) failed = 1 return failed -def test_additional_load_flexible_overnight_window(my_predbat): - """Test flexible additional load supports an overnight allowed window.""" +def test_additional_load_flexible_done_by_window(my_predbat): + """Test flexible end_time means done by, with omitted start_time using now.""" failed = 0 configure_additional_load_test(my_predbat) - my_predbat.minutes_now = 23 * 60 - configure_additional_load_rates(my_predbat, 25 * 60, 27 * 60) + my_predbat.minutes_now = 16 * 60 my_predbat.args["house_load_additional_forecast"] = [ - {"name": "dishwasher", "mode": "flexible", "start_time": "22:00", "end_time": "07:00", "duration": 2.0, "energy": 1.2}, + {"name": "dishwasher", "mode": "flexible", "end_time": "07:00", "duration": 2.0, "energy": 1.2}, ] - load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() - failed |= check_slot(load_adjust, 25 * 60, 0.3, "flexible overnight window") - failed |= check_slot(load_adjust, 26 * 60 + 30, 0.3, "flexible overnight window") + _, forecasts = my_predbat.fetch_additional_load_forecast() forecast = forecasts.get("dishwasher", {}) - if "T01:00:00" not in forecast.get("suggested_start", ""): - print("ERROR: Flexible overnight load should suggest 01:00, got {}".format(forecast)) + if "T16:00:00" not in forecast.get("requested_start", "") or "T07:00:00" not in forecast.get("requested_end", ""): + print("ERROR: Flexible done-by window should run from now until 07:00, got {}".format(forecast)) + failed = 1 + return failed + + +def test_additional_load_flexible_prediction_metric_selection(my_predbat): + """Test flexible additional load uses prediction metric, not raw import rate order.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.minutes_now = 16 * 60 + my_predbat.forecast_minutes = 24 * 60 + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "mode": "flexible", "end_time": "07:00", "duration": 2.0, "energy": 1.2}, + ] + my_predbat.house_load_additional_forecast_adjust, my_predbat.house_load_additional_forecasts = my_predbat.fetch_additional_load_forecast() + my_predbat.charge_limit_best = [] + my_predbat.charge_window_best = [] + my_predbat.export_window_best = [] + my_predbat.export_limits_best = [] + my_predbat.end_record = my_predbat.forecast_minutes + + original_prediction = plan_module.Prediction + + class FakePrediction: + """Fake prediction scores 01:00 as cheapest regardless of candidate order.""" + + def __init__(self, base, pv_step, pv10_step, load_step, load10_step): + """Store load step data.""" + self.load_step = load_step + + def run_prediction(self, charge_limit, charge_window, export_window, export_limits, pv10, end_record): + """Return a metric based on when the injected load appears.""" + metric = 1000.0 + first_load_minute = None + for minute, load in self.load_step.items(): + if load > 0: + first_load_minute = my_predbat.minutes_now + minute if first_load_minute is None else min(first_load_minute, my_predbat.minutes_now + minute) + if first_load_minute is not None: + metric = abs(first_load_minute - 25 * 60) + return (metric, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + + try: + plan_module.Prediction = FakePrediction + selected, _, _ = my_predbat.select_flexible_additional_loads({}, {}, {}, {}) + finally: + plan_module.Prediction = original_prediction + + if not selected: + print("ERROR: Flexible prediction metric selection should select a slot") + failed = 1 + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 25 * 60, 0.3, "flexible prediction metric") + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + if "T01:00:00" not in forecast.get("suggested_start", "") or forecast.get("selection_reason") != "prediction_metric": + print("ERROR: Flexible prediction metric should select 01:00, got {}".format(forecast)) failed = 1 return failed @@ -406,8 +461,10 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_select_api_override(my_predbat) failed |= test_additional_load_select_api_weighting(my_predbat) failed |= test_additional_load_select_event_updates_adjustment(my_predbat) - failed |= test_additional_load_switch_disables_and_enables(my_predbat) - failed |= test_additional_load_switch_enables_disabled_profile(my_predbat) - failed |= test_additional_load_flexible_cheapest_slot(my_predbat) - failed |= test_additional_load_flexible_overnight_window(my_predbat) + failed |= test_additional_load_delete_button_removes_api_forecast(my_predbat) + failed |= test_additional_load_yaml_does_not_publish_delete_button(my_predbat) + failed |= test_additional_load_api_forecast_auto_expires(my_predbat) + failed |= test_additional_load_flexible_pending_until_plan(my_predbat) + failed |= test_additional_load_flexible_done_by_window(my_predbat) + failed |= test_additional_load_flexible_prediction_metric_selection(my_predbat) return failed diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index c876c6d4d..14b397ead 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -413,20 +413,6 @@ async def switch_event(self, event, data, kwargs): for entity_id in entities: await self.components.switch_event(entity_id, service) - for entity_id in entities: - if entity_id.startswith("switch.{}_load_forecast_delta_".format(self.prefix)): - name = self.additional_load_name_from_entity(entity_id) - if name: - if service == "turn_on": - self.set_additional_load_enabled(name, True) - elif service == "turn_off": - self.set_additional_load_enabled(name, False) - elif service == "toggle": - forecast = self.house_load_additional_forecasts.get(name, {}) - self.set_additional_load_enabled(name, forecast.get("state", "off") != "on") - self.update_pending = True - self.plan_valid = False - for item in self.CONFIG_ITEMS: if ("entity" in item) and (item["entity"] in entities): value = item["value"] @@ -446,6 +432,24 @@ async def switch_event(self, event, data, kwargs): self.update_pending = True self.plan_valid = False + async def button_event(self, event, data, kwargs): + """ + Catch HA button press events. + """ + service_data = data.get("service_data", {}) + entities = service_data.get("entity_id", []) + + if isinstance(entities, str): + entities = [entities] + + for entity_id in entities: + if entity_id.startswith("button.{}_load_forecast_delta_".format(self.prefix)) and entity_id.endswith("_delete"): + name = self.additional_load_name_from_entity(entity_id) + if name: + self.delete_additional_load_forecast(name) + self.update_pending = True + self.plan_valid = False + def get_ha_config(self, name, default): """ Get Home assistant config value, use default if not set @@ -895,7 +899,7 @@ async def load_forecast_delta_event(self, service, data, kwargs): self.record_status("Warn: update_load_forecast_delta called without name or target entity_id", had_errors=True) return - forecast = {"name": str(name)} + forecast = {"name": str(name), "_source": "service", "_auto_expire": True} for key in ["start_time", "end_time", "duration", "energy", "slot_energy", "weighting", "enabled", "mode"]: if key in service_data: forecast[key] = service_data[key] @@ -914,6 +918,7 @@ def define_service_list(self): {"domain": "switch", "service": "turn_on"}, {"domain": "switch", "service": "turn_off"}, {"domain": "switch", "service": "toggle"}, + {"domain": "button", "service": "press"}, {"domain": "select", "service": "select_option"}, {"domain": "select", "service": "select_first"}, {"domain": "select", "service": "select_last"}, @@ -925,6 +930,7 @@ def define_service_list(self): {"domain": "switch", "service": "turn_on", "callback": self.switch_event}, {"domain": "switch", "service": "turn_off", "callback": self.switch_event}, {"domain": "switch", "service": "toggle", "callback": self.switch_event}, + {"domain": "button", "service": "press", "callback": self.button_event}, {"domain": "input_number", "service": "set_value", "callback": self.number_event}, {"domain": "input_number", "service": "increment", "callback": self.number_event}, {"domain": "input_number", "service": "decrement", "callback": self.number_event}, @@ -1214,6 +1220,9 @@ def manual_select(self, config_item, value): if value.startswith("+"): # Ignore selections which are just the current value return + if config_item == "load_forecast_delta_api" and value != "off": + name = value.split("?", 1)[0].split("=", 1)[0].replace("[", "").replace("]", "") + self.house_load_additional_forecast_overrides.pop(name, None) values = item.get("value", "") if not values: values = "" diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index b5509d715..c6faf0d22 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1658,7 +1658,7 @@ For advanced cases, use **slot_energy** when you want to set the kWh for each Pr The **slot_energy** value is kWh per Predbat plan slot, not the total energy for the full duration. With the default 30-minute plan interval, the example above adds 0.5kWh to each of the four slots from 20:00 to 22:00, for a total of 2.0kWh. With a 15-minute plan interval it would add 0.5kWh to each of eight slots, for a total of 4.0kWh. -Set **enabled** to `false` to leave a named load configured but disabled by default. Predbat will still publish the binary sensor and companion switch, but it will not add any load to the plan until the switch is turned on or the API sends `enabled=true`. +Set **enabled** to `false` to leave a named load configured but disabled by default. Predbat will still publish the binary sensor, but it will not add any load to the plan until the API sends `enabled=true`. ```yaml house_load_additional_forecast: @@ -1707,7 +1707,9 @@ Using total **energy** with weighting: With a 30-minute plan interval this redistributes 1.2kWh as 0.4kWh, 0.4kWh, 0.2kWh, and 0.2kWh. -Set **mode** to `flexible` when the load can run at any time within an allowed window. Predbat will place the duration into the cheapest block it can find using the current import rates. If **start_time** and **end_time** are omitted, the allowed window is the remaining forecast horizon. +Set **mode** to `flexible` when the load can run at any time before a deadline. For flexible loads, **start_time** means the earliest allowed start and **end_time** means the load must be done by that time. If **start_time** is omitted, Predbat uses the current plan slot; if **end_time** is omitted, Predbat uses the remaining forecast horizon. + +Predbat scores flexible candidates with the prediction metric, not just the import rate. This means the suggested time considers the current plan, solar forecast, battery state, import/export rates, losses, and other predicted load. ```yaml house_load_additional_forecast: @@ -1718,7 +1720,7 @@ Set **mode** to `flexible` when the load can run at any time within an allowed w energy: 1.2 ``` -You can also restrict a flexible load to a same-day or overnight window: +You can also restrict a flexible load to a same-day or overnight done-by window: ```yaml house_load_additional_forecast: @@ -1731,9 +1733,11 @@ You can also restrict a flexible load to a same-day or overnight window: energy: 1.2 ``` -Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. The sensor attributes also show **enabled**, **mode**, **energy**, **slot_energy**, **load_mode**, **plan_interval_minutes**, **slots**, **total_energy**, and for flexible loads **requested_start**, **requested_end**, **suggested_start**, and **suggested_end** so you can confirm how much load will be added and when. +If this is enabled at 16:00, the example above means the load may start any time from 22:00 and must finish by 07:00. If **start_time** is omitted, for example `end_time: "07:00"`, the load may start any time from now and must finish by 07:00. + +Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. The sensor attributes also show **enabled**, **mode**, **energy**, **slot_energy**, **load_mode**, **plan_interval_minutes**, **slots**, **total_energy**, **source**, **auto_expire**, **expires_at**, and for flexible loads **requested_start**, **requested_end**, **suggested_start**, **suggested_end**, **selection_reason**, and **candidate_count** so you can confirm how much load will be added and when. -Predbat also publishes a companion switch for each named load, for example **switch.predbat_load_forecast_delta_dishwasher**. Turn this switch off to temporarily disable that forecast if you no longer want the scheduled load included in the plan; turn it back on to re-enable the configured or API-supplied forecast. +Forecasts created through **select.predbat_load_forecast_delta_api** are one-shot dynamic loads. Predbat publishes a delete button for these forecasts, for example **button.predbat_load_forecast_delta_dishwasher_delete**, and automatically removes them after their finish time. YAML entries are static load injections and do not get delete buttons; remove or edit them in `apps.yaml` instead. To update a named load from a Home Assistant automation, call **select.select_option** on **select.predbat_load_forecast_delta_api** with the format `name?start_time=HH:MM&duration=hours&energy=kWh`: @@ -1755,6 +1759,16 @@ data: option: "dishwasher_eco?enabled=true&mode=flexible&start_time=22:00&end_time=07:00&duration=2.0&energy=1.2" ``` +To run any time from now but be done by 07:00, omit **start_time**: + +```yaml +action: select.select_option +target: + entity_id: select.predbat_load_forecast_delta_api +data: + option: "dishwasher_eco?enabled=true&mode=flexible&end_time=07:00&duration=2.0&energy=1.2" +``` + ## Balance Inverters When you have two or more inverters it's possible they get out of sync so they are at different charge levels or they start to cross-charge (one discharges into another). diff --git a/docs/manual-api.md b/docs/manual-api.md index 3c076acf3..c5d6aabdd 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -172,9 +172,9 @@ data: The **energy** value is the total kWh across the full duration. Predbat divides it across the generated plan slots. -To cancel a scheduled named load, turn off its companion switch, for example **switch.predbat_load_forecast_delta_dishwasher**. Turning the switch back on re-enables the configured or API-supplied forecast. +Forecasts created through **select.predbat_load_forecast_delta_api** are one-shot dynamic loads. Predbat publishes a delete button for each of these forecasts, for example **button.predbat_load_forecast_delta_dishwasher_delete**, and automatically removes the forecast after its finish time. If you want the same forecast again, send the select command again. -If the appliance can run at any time, send `mode=flexible`. Predbat will choose the cheapest available block from the requested window, or from the remaining forecast horizon if no window is supplied: +If the appliance can run at any time before a deadline, send `mode=flexible`. For flexible loads, `start_time` is the earliest allowed start and `end_time` means done by. Predbat chooses the best block using the full prediction metric, so the selection considers solar, battery state, import/export rates, losses, and the current plan rather than just the import rate: ```yaml action: select.select_option @@ -184,7 +184,17 @@ data: option: "dishwasher?enabled=true&mode=flexible&start_time=22:00&end_time=07:00&duration=2.0&energy=1.2" ``` -Use `enabled=false` in `apps.yaml` to keep reusable appliance profiles visible but inactive until an automation or the companion switch enables them. +To allow the dishwasher to start any time from now but be done by 07:00, omit `start_time`: + +```yaml +action: select.select_option +target: + entity_id: select.predbat_load_forecast_delta_api +data: + option: "dishwasher?enabled=true&mode=flexible&end_time=07:00&duration=2.0&energy=1.2" +``` + +Use `enabled=false` in `apps.yaml` to keep static load injection profiles visible but inactive until an automation sends an API forecast with the same name. For advanced cases, you can use **slot_energy** instead when you want to set kWh per Predbat plan slot directly. With the default 30-minute plan interval, `slot_energy: 0.5` adds 0.5kWh to each slot for two hours. From 6534171d3d82163e1d57696b17b312c7a17fa575 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 07:58:36 +0200 Subject: [PATCH 08/25] Fix dynamic load forecast cleanup --- apps/predbat/fetch.py | 52 ++++++++++- apps/predbat/ha.py | 18 +++- apps/predbat/plan.py | 2 +- apps/predbat/predbat.py | 12 +++ .../tests/test_additional_load_forecast.py | 93 +++++++++++++++++++ apps/predbat/userinterface.py | 6 +- 6 files changed, 172 insertions(+), 11 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 6f92a14a4..21fd3ff8f 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -176,9 +176,31 @@ def delete_additional_load_forecast(self, name): Delete a named one-shot additional load forecast. """ name = str(name) + if not self.has_additional_load_api_command(name) and name not in self.house_load_additional_forecast_overrides: + self.log("Warn: Ignoring delete for inactive additional load forecast {}".format(name)) + return False self.house_load_additional_forecast_overrides.pop(name, None) self.remove_additional_load_api_command(name) self.refresh_additional_load_forecast_api() + return True + + def has_additional_load_api_command(self, name): + """ + Return True if a named forecast command is active in the load_forecast_delta_api selector. + """ + item = self.config_index.get("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else None + if not item: + return False + values = item.get("value", "") or "" + values = values.replace("+", "") + values_list = values.split(",") if values else [] + for value in values_list: + if value == "off": + continue + command_name = value.split("?", 1)[0].split("=", 1)[0].replace("[", "").replace("]", "") + if command_name == name: + return True + return False def remove_additional_load_api_command(self, name): """ @@ -325,9 +347,9 @@ def get_additional_load_forecast_config(self): forecast_items.append(load_item) self.expire_additional_load_api_commands() - runtime_overrides = {} - runtime_overrides.update(self.get_additional_load_api_overrides()) - runtime_overrides.update(self.house_load_additional_forecast_overrides) + runtime_overrides = self.get_additional_load_api_overrides() + for name, override in self.house_load_additional_forecast_overrides.items(): + runtime_overrides.setdefault(name, {}).update(override) for name, override in runtime_overrides.items(): found = False for index, load_item in enumerate(forecast_items): @@ -393,6 +415,9 @@ def fetch_additional_load_forecast(self, selected_flexible=None): if auto_expire and source != "yaml" and expires_minutes is not None: self.house_load_additional_forecast_overrides.setdefault(name, {"name": name})["_expires_minutes"] = expires_minutes + if source == "yaml" and energy_total is None and slot_energy == 0 and duration == 0 and not duration_configured: + continue + if not enabled or start_minutes is None or (energy_total is None and slot_energy == 0) or (energy_total == 0) or duration == 0 or end_minutes is None: forecasts[name] = { "entity_id": entity_id, @@ -521,6 +546,9 @@ def publish_additional_load_forecasts(self): """ Publish named additional load forecast binary sensors for visibility and automation targeting. """ + if not hasattr(self, "house_load_additional_forecast_entities"): + self.house_load_additional_forecast_entities = set() + published_entities = set() for name, forecast in self.house_load_additional_forecasts.items(): attributes = { "friendly_name": "Predbat load forecast delta {}".format(name), @@ -550,9 +578,11 @@ def publish_additional_load_forecasts(self): "target_times": forecast.get("target_times", []), } self.dashboard_item(forecast["entity_id"], state=forecast.get("state", "off"), attributes=attributes) + published_entities.add(forecast["entity_id"]) if forecast.get("source", "yaml") != "yaml": + delete_entity = self.additional_load_delete_entity_name(name) self.dashboard_item( - self.additional_load_delete_entity_name(name), + delete_entity, state="idle", attributes={ "friendly_name": "Delete Predbat load forecast delta {}".format(name), @@ -561,6 +591,20 @@ def publish_additional_load_forecasts(self): "source": forecast.get("source", "api"), }, ) + published_entities.add(delete_entity) + for entity_id in self.house_load_additional_forecast_entities - published_entities: + self.unpublish_additional_load_entity(entity_id) + self.house_load_additional_forecast_entities = published_entities + + def unpublish_additional_load_entity(self, entity_id): + """ + Remove a stale additional load forecast entity from HA and the local dashboard cache. + """ + if hasattr(self, "delete_state_wrapper"): + self.delete_state_wrapper(entity_id) + self.dashboard_values.pop(entity_id, None) + if entity_id in self.dashboard_index: + self.dashboard_index.remove(entity_id) def filtered_today(self, time_data, resetmidnight=False, stamp=None): """ diff --git a/apps/predbat/ha.py b/apps/predbat/ha.py index 5e5fea180..307fc52c0 100644 --- a/apps/predbat/ha.py +++ b/apps/predbat/ha.py @@ -890,13 +890,23 @@ 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. + """ + self.db_mirror_list.pop(entity_id, None) + self.state_data.pop(entity_id.lower(), None) + if self.ha_key: + self.api_call("/api/states/{}".format(entity_id), delete=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. @@ -918,7 +928,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: @@ -928,7 +940,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) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 1be51efc4..8f5de8e86 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -171,7 +171,7 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 "_expires_minutes": best_start + duration_minutes if forecast.get("auto_expire", False) else None, } if forecast.get("auto_expire", False): - self.house_load_additional_forecast_overrides[name] = {"name": name, "_expires_minutes": best_start + duration_minutes} + self.house_load_additional_forecast_overrides[name] = {"name": name, **selected_flexible[name]} self.log("Flexible additional load {} selected {}-{} using prediction metric {} from {} candidates".format(name, self.time_abs_str(best_start), self.time_abs_str(best_start + duration_minutes), dp2(best_metric), candidate_count)) if not selected_flexible: diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index f8042d635..7f5a96385 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -333,6 +333,18 @@ def set_state_wrapper(self, entity_id, state, attributes={}, required_unit=None) state = self.unit_conversion(entity_id, state, None, required_unit, going_to=True) return self.ha_interface.set_state(entity_id, state, attributes=attributes) + def delete_state_wrapper(self, entity_id): + """ + Wrapper function to delete state from HA. + """ + if not self.ha_interface: + self.log("Error: delete_state_wrapper - No HA interface available") + return False + if not hasattr(self.ha_interface, "delete_state"): + return False + + return self.ha_interface.delete_state(entity_id) + def fire_event_wrapper(self, domain, service): """ Wrapper function to fire a HA event diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index ed4de1d8d..476ade41d 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -21,6 +21,7 @@ def configure_additional_load_test(my_predbat): my_predbat.forecast_minutes = 24 * 60 my_predbat.args["plan_interval_minutes"] = 30 my_predbat.house_load_additional_forecast_overrides = {} + my_predbat.house_load_additional_forecast_entities = set() def configure_additional_load_rates(my_predbat, cheap_start, cheap_end): @@ -312,6 +313,12 @@ def test_additional_load_delete_button_removes_api_forecast(my_predbat): if my_predbat.api_select_update("load_forecast_delta_api"): print("ERROR: Delete button should clear API forecast") failed = 1 + if "binary_sensor.predbat_load_forecast_delta_dishwasher" in my_predbat.dashboard_values: + print("ERROR: Delete button should remove dishwasher binary sensor") + failed = 1 + if "button.predbat_load_forecast_delta_dishwasher_delete" in my_predbat.dashboard_values: + print("ERROR: Delete button should remove dishwasher delete button") + failed = 1 my_predbat.house_load_additional_forecast_overrides = {} return failed @@ -352,7 +359,90 @@ def test_additional_load_api_forecast_auto_expires(my_predbat): if my_predbat.house_load_additional_forecasts: print("ERROR: Expired API forecast should not remain active, got {}".format(my_predbat.house_load_additional_forecasts)) failed = 1 + if "binary_sensor.predbat_load_forecast_delta_dishwasher" in my_predbat.dashboard_values: + print("ERROR: Expired API forecast should remove dishwasher binary sensor") + failed = 1 + if "button.predbat_load_forecast_delta_dishwasher_delete" in my_predbat.dashboard_values: + print("ERROR: Expired API forecast should remove dishwasher delete button") + failed = 1 + + my_predbat.house_load_additional_forecast_overrides = {} + return failed + +def test_additional_load_yaml_placeholder_not_published(my_predbat): + """Test empty YAML placeholders do not publish dead forecast entities.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher"}, + ] + my_predbat.refresh_additional_load_forecast_api() + + if "binary_sensor.predbat_load_forecast_delta_dishwasher" in my_predbat.dashboard_values: + print("ERROR: Empty YAML placeholder should not publish dishwasher binary sensor") + failed = 1 + + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + +def test_additional_load_stale_delete_button_no_replan(my_predbat): + """Test stale delete button press does not invalidate a plan.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.update_pending = False + my_predbat.plan_valid = True + + service_data = { + "domain": "button", + "service": "press", + "service_data": {"entity_id": "button.predbat_load_forecast_delta_dishwasher_delete"}, + } + run_async(my_predbat.trigger_callback(service_data)) + if my_predbat.update_pending or not my_predbat.plan_valid: + print("ERROR: Stale delete button should not invalidate plan") + failed = 1 + + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + +def test_additional_load_flexible_api_selection_survives_refresh(my_predbat): + """Test selected flexible API metadata augments, not replaces, the API command.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.plan_interval_minutes = 15 + my_predbat.args["plan_interval_minutes"] = 15 + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.api_select("load_forecast_delta_api", "dishwasher?enabled=true&mode=flexible&end_time=22:00&duration=2.0&energy=1.2") + my_predbat.house_load_additional_forecast_overrides["dishwasher"] = { + "name": "dishwasher", + "_selected_start_minutes": 11 * 60 + 15, + "_selection_reason": "prediction_metric", + "_candidate_count": 50, + "_selected_metric": 1615.32, + "_baseline_metric": 1600.0, + "_expires_minutes": 13 * 60 + 15, + } + my_predbat.refresh_additional_load_forecast_api() + + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + if forecast.get("source") != "api" or not forecast.get("auto_expire"): + print("ERROR: Flexible API forecast should keep API source after selection refresh, got {}".format(forecast)) + failed = 1 + if forecast.get("mode") != "flexible" or forecast.get("energy") != 1.2 or forecast.get("duration") != 2.0: + print("ERROR: Flexible API forecast should keep command fields after selection refresh, got {}".format(forecast)) + failed = 1 + if forecast.get("state") != "on" or forecast.get("slots") != 8 or not forecast.get("target_times"): + print("ERROR: Flexible API forecast should keep selected target slots after refresh, got {}".format(forecast)) + failed = 1 + if "T11:15:00" not in forecast.get("suggested_start", "") or "T13:15:00" not in forecast.get("suggested_end", ""): + print("ERROR: Flexible API forecast should publish selected window after refresh, got {}".format(forecast)) + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") my_predbat.house_load_additional_forecast_overrides = {} return failed @@ -464,6 +554,9 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_delete_button_removes_api_forecast(my_predbat) failed |= test_additional_load_yaml_does_not_publish_delete_button(my_predbat) failed |= test_additional_load_api_forecast_auto_expires(my_predbat) + failed |= test_additional_load_yaml_placeholder_not_published(my_predbat) + failed |= test_additional_load_stale_delete_button_no_replan(my_predbat) + failed |= test_additional_load_flexible_api_selection_survives_refresh(my_predbat) failed |= test_additional_load_flexible_pending_until_plan(my_predbat) failed |= test_additional_load_flexible_done_by_window(my_predbat) failed |= test_additional_load_flexible_prediction_metric_selection(my_predbat) diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 14b397ead..5cc44b73e 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -446,9 +446,9 @@ async def button_event(self, event, data, kwargs): if entity_id.startswith("button.{}_load_forecast_delta_".format(self.prefix)) and entity_id.endswith("_delete"): name = self.additional_load_name_from_entity(entity_id) if name: - self.delete_additional_load_forecast(name) - self.update_pending = True - self.plan_valid = False + if self.delete_additional_load_forecast(name): + self.update_pending = True + self.plan_valid = False def get_ha_config(self, name, default): """ From 60e33a790fb24e9b77ca300af4d0d11c6cab6a1b Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 08:03:02 +0200 Subject: [PATCH 09/25] Clean stale dynamic load entities --- apps/predbat/fetch.py | 10 ++++++++++ apps/predbat/tests/test_additional_load_forecast.py | 10 ++++++++++ 2 files changed, 20 insertions(+) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 21fd3ff8f..884d85a90 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -178,12 +178,22 @@ def delete_additional_load_forecast(self, name): name = str(name) if not self.has_additional_load_api_command(name) and name not in self.house_load_additional_forecast_overrides: self.log("Warn: Ignoring delete for inactive additional load forecast {}".format(name)) + self.unpublish_additional_load_name(name) return False self.house_load_additional_forecast_overrides.pop(name, None) self.remove_additional_load_api_command(name) self.refresh_additional_load_forecast_api() return True + def unpublish_additional_load_name(self, name): + """ + Remove stale additional load forecast entities for a named forecast without replanning. + """ + for entity_id in [self.additional_load_entity_name(name), self.additional_load_delete_entity_name(name)]: + self.unpublish_additional_load_entity(entity_id) + if hasattr(self, "house_load_additional_forecast_entities"): + self.house_load_additional_forecast_entities.discard(entity_id) + def has_additional_load_api_command(self, name): """ Return True if a named forecast command is active in the load_forecast_delta_api selector. diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 476ade41d..b3beeb38a 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -392,6 +392,10 @@ def test_additional_load_stale_delete_button_no_replan(my_predbat): failed = 0 configure_additional_load_test(my_predbat) my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.dashboard_values["binary_sensor.predbat_load_forecast_delta_dishwasher"] = {"state": "off", "attributes": {}} + my_predbat.dashboard_values["button.predbat_load_forecast_delta_dishwasher_delete"] = {"state": "idle", "attributes": {}} + my_predbat.dashboard_index.append("binary_sensor.predbat_load_forecast_delta_dishwasher") + my_predbat.dashboard_index.append("button.predbat_load_forecast_delta_dishwasher_delete") my_predbat.update_pending = False my_predbat.plan_valid = True @@ -404,6 +408,12 @@ def test_additional_load_stale_delete_button_no_replan(my_predbat): if my_predbat.update_pending or not my_predbat.plan_valid: print("ERROR: Stale delete button should not invalidate plan") failed = 1 + if "binary_sensor.predbat_load_forecast_delta_dishwasher" in my_predbat.dashboard_values: + print("ERROR: Stale delete button should remove stale dishwasher binary sensor") + failed = 1 + if "button.predbat_load_forecast_delta_dishwasher_delete" in my_predbat.dashboard_values: + print("ERROR: Stale delete button should remove stale dishwasher delete button") + failed = 1 my_predbat.house_load_additional_forecast_overrides = {} return failed From aa40a4818c74044175cc736ec1b17660bb8a216a Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 08:14:24 +0200 Subject: [PATCH 10/25] Add flexible load candidate diagnostics --- apps/predbat/fetch.py | 4 ++++ apps/predbat/plan.py | 10 ++++++++++ apps/predbat/tests/test_additional_load_forecast.py | 9 +++++++++ 3 files changed, 23 insertions(+) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 884d85a90..b614ea47c 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -451,6 +451,7 @@ def fetch_additional_load_forecast(self, selected_flexible=None): "candidate_count": 0, "selected_metric": None, "baseline_metric": None, + "candidate_scores": [], "source": source, "auto_expire": auto_expire, "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, @@ -485,6 +486,7 @@ def fetch_additional_load_forecast(self, selected_flexible=None): "candidate_count": 0, "selected_metric": None, "baseline_metric": None, + "candidate_scores": [], "source": source, "auto_expire": auto_expire, "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, @@ -540,6 +542,7 @@ def fetch_additional_load_forecast(self, selected_flexible=None): "candidate_count": load_item.get("_candidate_count", 0), "selected_metric": load_item.get("_selected_metric", None), "baseline_metric": load_item.get("_baseline_metric", None), + "candidate_scores": load_item.get("_candidate_scores", []), "source": source, "auto_expire": auto_expire, "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, @@ -582,6 +585,7 @@ def publish_additional_load_forecasts(self): "candidate_count": forecast.get("candidate_count", 0), "selected_metric": forecast.get("selected_metric", None), "baseline_metric": forecast.get("baseline_metric", None), + "candidate_scores": forecast.get("candidate_scores", []), "source": forecast.get("source", "yaml"), "auto_expire": forecast.get("auto_expire", False), "expires_at": forecast.get("expires_at", None), diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 8f5de8e86..2c04e7dd8 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -145,6 +145,7 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 best_start = None best_metric = None candidate_count = 0 + candidate_scores = [] while candidate <= latest_start: candidate_adjust, _, _ = self.additional_load_candidate_profile(forecast, candidate) @@ -153,6 +154,13 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 candidate_prediction = Prediction(self, pv_forecast_minute_step, pv_forecast_minute10_step, candidate_load_step, candidate_load_step10) candidate_metric = candidate_prediction.run_prediction(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, False, self.end_record)[0] candidate_count += 1 + candidate_scores.append( + { + "start": (self.midnight_utc + timedelta(minutes=candidate)).isoformat(), + "end": (self.midnight_utc + timedelta(minutes=candidate + duration_minutes)).isoformat(), + "metric": dp2(candidate_metric), + } + ) if best_metric is None or candidate_metric < best_metric: best_metric = candidate_metric best_start = candidate @@ -168,11 +176,13 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 "_candidate_count": candidate_count, "_selected_metric": dp2(best_metric) if best_metric is not None else None, "_baseline_metric": dp2(baseline_metric), + "_candidate_scores": sorted(candidate_scores, key=lambda score: score["metric"]), "_expires_minutes": best_start + duration_minutes if forecast.get("auto_expire", False) else None, } if forecast.get("auto_expire", False): self.house_load_additional_forecast_overrides[name] = {"name": name, **selected_flexible[name]} self.log("Flexible additional load {} selected {}-{} using prediction metric {} from {} candidates".format(name, self.time_abs_str(best_start), self.time_abs_str(best_start + duration_minutes), dp2(best_metric), candidate_count)) + self.log("Flexible additional load {} best candidates {}".format(name, selected_flexible[name]["_candidate_scores"][:10])) if not selected_flexible: return False, load_minutes_step, load_minutes_step10 diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index b3beeb38a..17ad7ea8d 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -434,6 +434,9 @@ def test_additional_load_flexible_api_selection_survives_refresh(my_predbat): "_candidate_count": 50, "_selected_metric": 1615.32, "_baseline_metric": 1600.0, + "_candidate_scores": [ + {"start": "2026-05-07T11:15:00+02:00", "end": "2026-05-07T13:15:00+02:00", "metric": 1615.32}, + ], "_expires_minutes": 13 * 60 + 15, } my_predbat.refresh_additional_load_forecast_api() @@ -451,6 +454,9 @@ def test_additional_load_flexible_api_selection_survives_refresh(my_predbat): if "T11:15:00" not in forecast.get("suggested_start", "") or "T13:15:00" not in forecast.get("suggested_end", ""): print("ERROR: Flexible API forecast should publish selected window after refresh, got {}".format(forecast)) failed = 1 + if not forecast.get("candidate_scores"): + print("ERROR: Flexible API forecast should keep candidate scores after refresh, got {}".format(forecast)) + failed = 1 my_predbat.api_select("load_forecast_delta_api", "off") my_predbat.house_load_additional_forecast_overrides = {} @@ -543,6 +549,9 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi if "T01:00:00" not in forecast.get("suggested_start", "") or forecast.get("selection_reason") != "prediction_metric": print("ERROR: Flexible prediction metric should select 01:00, got {}".format(forecast)) failed = 1 + if not forecast.get("candidate_scores") or forecast.get("candidate_scores", [{}])[0].get("metric") != 0: + print("ERROR: Flexible prediction metric should publish sorted candidate scores, got {}".format(forecast)) + failed = 1 return failed From 5c706fb125246d612bf6762718d5c42da98c55eb Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 08:20:45 +0200 Subject: [PATCH 11/25] Log flexible load rate diagnostics --- apps/predbat/plan.py | 35 ++++++++++++++----- .../tests/test_additional_load_forecast.py | 5 +++ 2 files changed, 32 insertions(+), 8 deletions(-) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 2c04e7dd8..1e3200f93 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -114,6 +114,25 @@ def add_additional_load_to_step_data(self, load_minutes_step, load_adjust): modified_load[step_minute] = dp4(modified_load.get(step_minute, 0.0) + energy * PREDICT_STEP / float(self.plan_interval_minutes)) return modified_load + def additional_load_candidate_rate_stats(self, start_minutes, duration_minutes): + """ + Return import/export rate summary for one flexible load candidate window. + """ + end_minutes = start_minutes + duration_minutes + import_rates = [self.rate_import.get(minute, 0.0) for minute in range(start_minutes, end_minutes)] + export_rates = [self.rate_export.get(minute, 0.0) for minute in range(start_minutes, end_minutes)] + stats = {} + for prefix, rates in [("import", import_rates), ("export", export_rates)]: + if rates: + stats["{}_rate_avg".format(prefix)] = dp2(sum(rates) / len(rates)) + stats["{}_rate_min".format(prefix)] = dp2(min(rates)) + stats["{}_rate_max".format(prefix)] = dp2(max(rates)) + else: + stats["{}_rate_avg".format(prefix)] = None + stats["{}_rate_min".format(prefix)] = None + stats["{}_rate_max".format(prefix)] = None + return stats + def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step10, pv_forecast_minute_step, pv_forecast_minute10_step): """ Select flexible additional load start times using full prediction metric impact. @@ -154,13 +173,13 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 candidate_prediction = Prediction(self, pv_forecast_minute_step, pv_forecast_minute10_step, candidate_load_step, candidate_load_step10) candidate_metric = candidate_prediction.run_prediction(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, False, self.end_record)[0] candidate_count += 1 - candidate_scores.append( - { - "start": (self.midnight_utc + timedelta(minutes=candidate)).isoformat(), - "end": (self.midnight_utc + timedelta(minutes=candidate + duration_minutes)).isoformat(), - "metric": dp2(candidate_metric), - } - ) + candidate_score = { + "start": (self.midnight_utc + timedelta(minutes=candidate)).isoformat(), + "end": (self.midnight_utc + timedelta(minutes=candidate + duration_minutes)).isoformat(), + "metric": dp2(candidate_metric), + } + candidate_score.update(self.additional_load_candidate_rate_stats(candidate, duration_minutes)) + candidate_scores.append(candidate_score) if best_metric is None or candidate_metric < best_metric: best_metric = candidate_metric best_start = candidate @@ -182,7 +201,7 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 if forecast.get("auto_expire", False): self.house_load_additional_forecast_overrides[name] = {"name": name, **selected_flexible[name]} self.log("Flexible additional load {} selected {}-{} using prediction metric {} from {} candidates".format(name, self.time_abs_str(best_start), self.time_abs_str(best_start + duration_minutes), dp2(best_metric), candidate_count)) - self.log("Flexible additional load {} best candidates {}".format(name, selected_flexible[name]["_candidate_scores"][:10])) + self.log("Flexible additional load {} candidate scores {}".format(name, selected_flexible[name]["_candidate_scores"])) if not selected_flexible: return False, load_minutes_step, load_minutes_step10 diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 17ad7ea8d..27b2fe3f3 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -509,6 +509,8 @@ def test_additional_load_flexible_prediction_metric_selection(my_predbat): {"name": "dishwasher", "mode": "flexible", "end_time": "07:00", "duration": 2.0, "energy": 1.2}, ] my_predbat.house_load_additional_forecast_adjust, my_predbat.house_load_additional_forecasts = my_predbat.fetch_additional_load_forecast() + my_predbat.rate_import = {minute: 20.0 for minute in range(0, 3 * 24 * 60)} + my_predbat.rate_export = {minute: 5.0 for minute in range(0, 3 * 24 * 60)} my_predbat.charge_limit_best = [] my_predbat.charge_window_best = [] my_predbat.export_window_best = [] @@ -552,6 +554,9 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi if not forecast.get("candidate_scores") or forecast.get("candidate_scores", [{}])[0].get("metric") != 0: print("ERROR: Flexible prediction metric should publish sorted candidate scores, got {}".format(forecast)) failed = 1 + if forecast.get("candidate_scores", [{}])[0].get("import_rate_avg") != 20.0 or forecast.get("candidate_scores", [{}])[0].get("export_rate_avg") != 5.0: + print("ERROR: Flexible prediction metric should publish candidate rate stats, got {}".format(forecast)) + failed = 1 return failed From 7ad1692eec2b8168d4c2cf0e96c3705407e83b83 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 08:32:26 +0200 Subject: [PATCH 12/25] Summarise planned additional loads --- apps/predbat/fetch.py | 4 -- apps/predbat/output.py | 39 ++++++++++++++++++ apps/predbat/plan.py | 29 ------------- .../tests/test_additional_load_forecast.py | 41 +++++++++++++------ 4 files changed, 67 insertions(+), 46 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index b614ea47c..884d85a90 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -451,7 +451,6 @@ def fetch_additional_load_forecast(self, selected_flexible=None): "candidate_count": 0, "selected_metric": None, "baseline_metric": None, - "candidate_scores": [], "source": source, "auto_expire": auto_expire, "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, @@ -486,7 +485,6 @@ def fetch_additional_load_forecast(self, selected_flexible=None): "candidate_count": 0, "selected_metric": None, "baseline_metric": None, - "candidate_scores": [], "source": source, "auto_expire": auto_expire, "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, @@ -542,7 +540,6 @@ def fetch_additional_load_forecast(self, selected_flexible=None): "candidate_count": load_item.get("_candidate_count", 0), "selected_metric": load_item.get("_selected_metric", None), "baseline_metric": load_item.get("_baseline_metric", None), - "candidate_scores": load_item.get("_candidate_scores", []), "source": source, "auto_expire": auto_expire, "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, @@ -585,7 +582,6 @@ def publish_additional_load_forecasts(self): "candidate_count": forecast.get("candidate_count", 0), "selected_metric": forecast.get("selected_metric", None), "baseline_metric": forecast.get("baseline_metric", None), - "candidate_scores": forecast.get("candidate_scores", []), "source": forecast.get("source", "yaml"), "auto_expire": forecast.get("auto_expire", False), "expires_at": forecast.get("expires_at", None), diff --git a/apps/predbat/output.py b/apps/predbat/output.py index fc496e0c4..eedd5bdb4 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -32,6 +32,43 @@ class Output: charging schedules, and financial metric summaries. """ + def additional_load_plan_time(self, timestamp): + """ + Return a compact local time string for an additional load timestamp. + """ + return datetime.fromisoformat(timestamp).strftime("%H:%M") + + def get_additional_load_text(self): + """ + Return a textual summary of confirmed planned additional load forecasts. + """ + planned_loads = [] + for name, forecast in sorted(getattr(self, "house_load_additional_forecasts", {}).items()): + target_times = forecast.get("target_times", []) + total_energy = forecast.get("total_energy", 0.0) + if not forecast.get("enabled", False) or not target_times or total_energy <= 0: + continue + start = target_times[0].get("start") + end = target_times[-1].get("end") + if not start or not end: + continue + planned_loads.append( + { + "name": name, + "start": start, + "end": end, + "text": "{} from {} to {} using {:.2f} kWh".format(name, self.additional_load_plan_time(start), self.additional_load_plan_time(end), dp2(total_energy)), + } + ) + + if not planned_loads: + return "" + + planned_loads = sorted(planned_loads, key=lambda load: load["start"]) + if len(planned_loads) == 1: + return "- Additional load {} is planned.\n".format(planned_loads[0]["text"]) + return "- Additional loads are planned: {}.\n".format("; ".join(load["text"] for load in planned_loads)) + def publish_car_plan(self): """ Publish the car charging plan @@ -915,6 +952,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: diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 1e3200f93..8f5de8e86 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -114,25 +114,6 @@ def add_additional_load_to_step_data(self, load_minutes_step, load_adjust): modified_load[step_minute] = dp4(modified_load.get(step_minute, 0.0) + energy * PREDICT_STEP / float(self.plan_interval_minutes)) return modified_load - def additional_load_candidate_rate_stats(self, start_minutes, duration_minutes): - """ - Return import/export rate summary for one flexible load candidate window. - """ - end_minutes = start_minutes + duration_minutes - import_rates = [self.rate_import.get(minute, 0.0) for minute in range(start_minutes, end_minutes)] - export_rates = [self.rate_export.get(minute, 0.0) for minute in range(start_minutes, end_minutes)] - stats = {} - for prefix, rates in [("import", import_rates), ("export", export_rates)]: - if rates: - stats["{}_rate_avg".format(prefix)] = dp2(sum(rates) / len(rates)) - stats["{}_rate_min".format(prefix)] = dp2(min(rates)) - stats["{}_rate_max".format(prefix)] = dp2(max(rates)) - else: - stats["{}_rate_avg".format(prefix)] = None - stats["{}_rate_min".format(prefix)] = None - stats["{}_rate_max".format(prefix)] = None - return stats - def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step10, pv_forecast_minute_step, pv_forecast_minute10_step): """ Select flexible additional load start times using full prediction metric impact. @@ -164,7 +145,6 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 best_start = None best_metric = None candidate_count = 0 - candidate_scores = [] while candidate <= latest_start: candidate_adjust, _, _ = self.additional_load_candidate_profile(forecast, candidate) @@ -173,13 +153,6 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 candidate_prediction = Prediction(self, pv_forecast_minute_step, pv_forecast_minute10_step, candidate_load_step, candidate_load_step10) candidate_metric = candidate_prediction.run_prediction(self.charge_limit_best, self.charge_window_best, self.export_window_best, self.export_limits_best, False, self.end_record)[0] candidate_count += 1 - candidate_score = { - "start": (self.midnight_utc + timedelta(minutes=candidate)).isoformat(), - "end": (self.midnight_utc + timedelta(minutes=candidate + duration_minutes)).isoformat(), - "metric": dp2(candidate_metric), - } - candidate_score.update(self.additional_load_candidate_rate_stats(candidate, duration_minutes)) - candidate_scores.append(candidate_score) if best_metric is None or candidate_metric < best_metric: best_metric = candidate_metric best_start = candidate @@ -195,13 +168,11 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 "_candidate_count": candidate_count, "_selected_metric": dp2(best_metric) if best_metric is not None else None, "_baseline_metric": dp2(baseline_metric), - "_candidate_scores": sorted(candidate_scores, key=lambda score: score["metric"]), "_expires_minutes": best_start + duration_minutes if forecast.get("auto_expire", False) else None, } if forecast.get("auto_expire", False): self.house_load_additional_forecast_overrides[name] = {"name": name, **selected_flexible[name]} self.log("Flexible additional load {} selected {}-{} using prediction metric {} from {} candidates".format(name, self.time_abs_str(best_start), self.time_abs_str(best_start + duration_minutes), dp2(best_metric), candidate_count)) - self.log("Flexible additional load {} candidate scores {}".format(name, selected_flexible[name]["_candidate_scores"])) if not selected_flexible: return False, load_minutes_step, load_minutes_step10 diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 27b2fe3f3..62d6b997a 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -22,6 +22,8 @@ def configure_additional_load_test(my_predbat): my_predbat.args["plan_interval_minutes"] = 30 my_predbat.house_load_additional_forecast_overrides = {} my_predbat.house_load_additional_forecast_entities = set() + my_predbat.house_load_additional_forecasts = {} + my_predbat.house_load_additional_forecast_adjust = {} def configure_additional_load_rates(my_predbat, cheap_start, cheap_end): @@ -434,9 +436,6 @@ def test_additional_load_flexible_api_selection_survives_refresh(my_predbat): "_candidate_count": 50, "_selected_metric": 1615.32, "_baseline_metric": 1600.0, - "_candidate_scores": [ - {"start": "2026-05-07T11:15:00+02:00", "end": "2026-05-07T13:15:00+02:00", "metric": 1615.32}, - ], "_expires_minutes": 13 * 60 + 15, } my_predbat.refresh_additional_load_forecast_api() @@ -454,10 +453,6 @@ def test_additional_load_flexible_api_selection_survives_refresh(my_predbat): if "T11:15:00" not in forecast.get("suggested_start", "") or "T13:15:00" not in forecast.get("suggested_end", ""): print("ERROR: Flexible API forecast should publish selected window after refresh, got {}".format(forecast)) failed = 1 - if not forecast.get("candidate_scores"): - print("ERROR: Flexible API forecast should keep candidate scores after refresh, got {}".format(forecast)) - failed = 1 - my_predbat.api_select("load_forecast_delta_api", "off") my_predbat.house_load_additional_forecast_overrides = {} return failed @@ -509,8 +504,6 @@ def test_additional_load_flexible_prediction_metric_selection(my_predbat): {"name": "dishwasher", "mode": "flexible", "end_time": "07:00", "duration": 2.0, "energy": 1.2}, ] my_predbat.house_load_additional_forecast_adjust, my_predbat.house_load_additional_forecasts = my_predbat.fetch_additional_load_forecast() - my_predbat.rate_import = {minute: 20.0 for minute in range(0, 3 * 24 * 60)} - my_predbat.rate_export = {minute: 5.0 for minute in range(0, 3 * 24 * 60)} my_predbat.charge_limit_best = [] my_predbat.charge_window_best = [] my_predbat.export_window_best = [] @@ -551,12 +544,33 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi if "T01:00:00" not in forecast.get("suggested_start", "") or forecast.get("selection_reason") != "prediction_metric": print("ERROR: Flexible prediction metric should select 01:00, got {}".format(forecast)) failed = 1 - if not forecast.get("candidate_scores") or forecast.get("candidate_scores", [{}])[0].get("metric") != 0: - print("ERROR: Flexible prediction metric should publish sorted candidate scores, got {}".format(forecast)) + return failed + + +def test_additional_load_textual_plan_summary(my_predbat): + """Test textual plan includes confirmed additional load forecasts only.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.house_load_additional_forecasts = { + "dishwasher": { + "enabled": True, + "total_energy": 1.2, + "target_times": [ + {"start": "2026-05-07T10:00:00+02:00", "end": "2026-05-07T10:30:00+02:00", "energy": 0.6}, + {"start": "2026-05-07T10:30:00+02:00", "end": "2026-05-07T11:00:00+02:00", "energy": 0.6}, + ], + }, + "pending": {"enabled": True, "total_energy": 1.0, "target_times": []}, + } + + text = my_predbat.get_additional_load_text() + if "Additional load dishwasher from 10:00 to 11:00 using 1.20 kWh is planned" not in text: + print("ERROR: Textual plan should include planned dishwasher load, got {}".format(text)) failed = 1 - if forecast.get("candidate_scores", [{}])[0].get("import_rate_avg") != 20.0 or forecast.get("candidate_scores", [{}])[0].get("export_rate_avg") != 5.0: - print("ERROR: Flexible prediction metric should publish candidate rate stats, got {}".format(forecast)) + if "pending" in text: + print("ERROR: Textual plan should not include pending load, got {}".format(text)) failed = 1 + my_predbat.house_load_additional_forecasts = {} return failed @@ -584,4 +598,5 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_flexible_pending_until_plan(my_predbat) failed |= test_additional_load_flexible_done_by_window(my_predbat) failed |= test_additional_load_flexible_prediction_metric_selection(my_predbat) + failed |= test_additional_load_textual_plan_summary(my_predbat) return failed From 9ca94280fceec631b9b42e121c04f4c13517ba84 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 12:33:51 +0200 Subject: [PATCH 13/25] Fix dynamic load forecast start drift --- apps/predbat/fetch.py | 12 +- apps/predbat/plan.py | 2 + .../tests/test_additional_load_forecast.py | 52 ++++++ apps/predbat/userinterface.py | 6 + docs/apps-yaml.md | 6 +- docs/manual-api.md | 158 ++++++++++++++++++ 6 files changed, 232 insertions(+), 4 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 884d85a90..a7f4bf170 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -236,7 +236,7 @@ def get_additional_load_window(self, load_item, mode, duration, plan_interval, m """ Return start/end minutes for fixed or flexible additional load scheduling. """ - start_minutes = self.get_additional_load_time_minutes(load_item, "start_time") if "start_time" in load_item else None + start_minutes = self.get_additional_load_time_minutes(load_item, "start_time") if "start_time" in load_item else load_item.get("_requested_start_minutes", None) end_minutes = self.get_additional_load_time_minutes(load_item, "end_time") if "end_time" in load_item else None duration_minutes = int(duration * 60) @@ -296,6 +296,14 @@ def parse_additional_load_api_command(self, api_command): override[arg_split[0]] = arg_split[1] else: override[arg_split[0]] = True + if "start_time" not in override: + existing_override = self.house_load_additional_forecast_overrides.get(str(name), {}) + requested_start_minutes = existing_override.get("_requested_start_minutes", None) + if requested_start_minutes is None: + plan_interval = self.get_arg("plan_interval_minutes", 30) + requested_start_minutes = int(self.minutes_now / plan_interval) * plan_interval + override["_requested_start_minutes"] = requested_start_minutes + self.house_load_additional_forecast_overrides.setdefault(str(name), {"name": str(name)})["_requested_start_minutes"] = requested_start_minutes return override def expire_additional_load_api_commands(self): @@ -408,7 +416,7 @@ def fetch_additional_load_forecast(self, selected_flexible=None): load_mode = "total_energy" if energy_total is not None else "slot_energy" total_energy = 0.0 start_minutes, end_minutes = self.get_additional_load_window(load_item, mode, duration, plan_interval, minutes_now_slot) - requested_start_minutes = start_minutes + requested_start_minutes = load_item.get("_requested_start_minutes", start_minutes) if "start_time" not in load_item else start_minutes requested_end_minutes = end_minutes if mode == "fixed" and duration <= 0 and not duration_configured and start_minutes is not None and end_minutes is not None: duration = (end_minutes - start_minutes) / 60.0 diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 8f5de8e86..f65b69b1b 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -163,6 +163,8 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 working_load_step = self.add_additional_load_to_step_data(working_load_step, best_adjust) working_load_step10 = self.add_additional_load_to_step_data(working_load_step10, best_adjust) selected_flexible[name] = { + "_requested_start_minutes": start_minutes, + "_requested_end_minutes": end_minutes, "_selected_start_minutes": best_start, "_selection_reason": "prediction_metric", "_candidate_count": candidate_count, diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 62d6b997a..9376bb6b9 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -494,6 +494,56 @@ def test_additional_load_flexible_done_by_window(my_predbat): return failed +def test_additional_load_flexible_api_omitted_start_is_frozen(my_predbat): + """Test API flexible forecasts without start_time keep their initial requested start.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.minutes_now = 16 * 60 + 15 + my_predbat.plan_interval_minutes = 15 + my_predbat.args["plan_interval_minutes"] = 15 + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.api_select("load_forecast_delta_api", "dishwasher?enabled=true&mode=flexible&end_time=07:00&duration=2.0&energy=1.2") + my_predbat.refresh_additional_load_forecast_api() + + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + first_requested_start = forecast.get("requested_start", "") + if "T16:15:00" not in first_requested_start: + print("ERROR: Flexible API omitted start should stamp initial plan slot, got {}".format(forecast)) + failed = 1 + + my_predbat.minutes_now = 17 * 60 + my_predbat.refresh_additional_load_forecast_api() + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + if forecast.get("requested_start", "") != first_requested_start: + print("ERROR: Flexible API omitted start should not drift after refresh, got {}".format(forecast)) + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + +def test_additional_load_flexible_yaml_omitted_start_rolls(my_predbat): + """Test YAML flexible forecasts without start_time continue using the current plan slot.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.minutes_now = 16 * 60 + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "mode": "flexible", "end_time": "07:00", "duration": 2.0, "energy": 1.2}, + ] + + _, forecasts = my_predbat.fetch_additional_load_forecast() + first_requested_start = forecasts.get("dishwasher", {}).get("requested_start", "") + my_predbat.minutes_now = 17 * 60 + _, forecasts = my_predbat.fetch_additional_load_forecast() + second_requested_start = forecasts.get("dishwasher", {}).get("requested_start", "") + + if "T16:00:00" not in first_requested_start or "T17:00:00" not in second_requested_start: + print("ERROR: Flexible YAML omitted start should roll with current time, got {} then {}".format(first_requested_start, second_requested_start)) + failed = 1 + return failed + + def test_additional_load_flexible_prediction_metric_selection(my_predbat): """Test flexible additional load uses prediction metric, not raw import rate order.""" failed = 0 @@ -597,6 +647,8 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_flexible_api_selection_survives_refresh(my_predbat) failed |= test_additional_load_flexible_pending_until_plan(my_predbat) failed |= test_additional_load_flexible_done_by_window(my_predbat) + failed |= test_additional_load_flexible_api_omitted_start_is_frozen(my_predbat) + failed |= test_additional_load_flexible_yaml_omitted_start_rolls(my_predbat) failed |= test_additional_load_flexible_prediction_metric_selection(my_predbat) failed |= test_additional_load_textual_plan_summary(my_predbat) return failed diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 5cc44b73e..f983fb1e5 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -903,6 +903,9 @@ async def load_forecast_delta_event(self, service, data, kwargs): for key in ["start_time", "end_time", "duration", "energy", "slot_energy", "weighting", "enabled", "mode"]: if key in service_data: forecast[key] = service_data[key] + if "start_time" not in forecast: + plan_interval = self.get_arg("plan_interval_minutes", 30) + forecast["_requested_start_minutes"] = int(self.minutes_now / plan_interval) * plan_interval self.house_load_additional_forecast_overrides[str(name)] = forecast await self.run_in_executor(self.refresh_additional_load_forecast_api) self.plan_valid = False @@ -1305,6 +1308,9 @@ def api_select(self, config_item, value): if value.startswith("+"): # Ignore selections which are just the current value return + if config_item == "load_forecast_delta_api" and value != "off" and "[" not in value: + name = value.split("?", 1)[0].split("=", 1)[0] + self.house_load_additional_forecast_overrides.pop(name, None) values = item.get("value", "") if not values: values = "" diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index c6faf0d22..914079bc1 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1707,7 +1707,7 @@ Using total **energy** with weighting: With a 30-minute plan interval this redistributes 1.2kWh as 0.4kWh, 0.4kWh, 0.2kWh, and 0.2kWh. -Set **mode** to `flexible` when the load can run at any time before a deadline. For flexible loads, **start_time** means the earliest allowed start and **end_time** means the load must be done by that time. If **start_time** is omitted, Predbat uses the current plan slot; if **end_time** is omitted, Predbat uses the remaining forecast horizon. +Set **mode** to `flexible` when the load can run at any time before a deadline. For flexible loads, **start_time** means the earliest allowed start and **end_time** means the load must be done by that time. If **start_time** is omitted in `apps.yaml`, Predbat uses the current plan slot each time the forecast is refreshed; if **end_time** is omitted, Predbat uses the remaining forecast horizon. Predbat scores flexible candidates with the prediction metric, not just the import rate. This means the suggested time considers the current plan, solar forecast, battery state, import/export rates, losses, and other predicted load. @@ -1733,7 +1733,7 @@ You can also restrict a flexible load to a same-day or overnight done-by window: energy: 1.2 ``` -If this is enabled at 16:00, the example above means the load may start any time from 22:00 and must finish by 07:00. If **start_time** is omitted, for example `end_time: "07:00"`, the load may start any time from now and must finish by 07:00. +If this is enabled at 16:00, the example above means the load may start any time from 22:00 and must finish by 07:00. If **start_time** is omitted, for example `end_time: "07:00"`, the YAML load may start any time from now and must finish by 07:00. Because YAML entries are static configuration, this "now" rolls forward on each forecast refresh. Predbat publishes each named load as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**, with a **target_times** attribute showing the generated slots. The sensor attributes also show **enabled**, **mode**, **energy**, **slot_energy**, **load_mode**, **plan_interval_minutes**, **slots**, **total_energy**, **source**, **auto_expire**, **expires_at**, and for flexible loads **requested_start**, **requested_end**, **suggested_start**, **suggested_end**, **selection_reason**, and **candidate_count** so you can confirm how much load will be added and when. @@ -1769,6 +1769,8 @@ data: option: "dishwasher_eco?enabled=true&mode=flexible&end_time=07:00&duration=2.0&energy=1.2" ``` +For one-shot forecasts created through **select.predbat_load_forecast_delta_api**, an omitted **start_time** is frozen to the current Predbat plan slot when the command is received. This prevents the published **requested_start** drifting forward on later replans. Sending the command again creates a fresh request and freezes a new start time. + ## Balance Inverters When you have two or more inverters it's possible they get out of sync so they are at different charge levels or they start to cross-charge (one discharges into another). diff --git a/docs/manual-api.md b/docs/manual-api.md index c5d6aabdd..fbabb9bd3 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -194,6 +194,164 @@ data: option: "dishwasher?enabled=true&mode=flexible&end_time=07:00&duration=2.0&energy=1.2" ``` +For one-shot forecasts created through **select.predbat_load_forecast_delta_api**, an omitted `start_time` is frozen to the current Predbat plan slot when the command is received. This prevents the published `requested_start` moving forward on later replans. If you send the same command again, Predbat treats it as a fresh request and freezes a new start time. Static YAML forecasts are different: if a YAML flexible load omits `start_time`, it continues to mean the current plan slot each time Predbat refreshes the forecast. + +Predbat publishes the result as a binary sensor, for example **binary_sensor.predbat_load_forecast_delta_dishwasher**. For flexible loads, the most useful attributes are **requested_start**, **requested_end**, **suggested_start**, **suggested_end**, **target_times**, and **expires_at**. + +A typical dishwasher automation can therefore send a one-shot flexible request when the dishwasher is ready: + +```yaml +action: select.select_option +target: + entity_id: select.predbat_load_forecast_delta_api +data: + option: "dishwasher?enabled=true&mode=flexible&end_time=07:00&duration=5&energy=0.7" +``` + +Use **suggested_start** from **binary_sensor.predbat_load_forecast_delta_dishwasher** to trigger the appliance start automation. Use **button.predbat_load_forecast_delta_dishwasher_delete** if you need to cancel the one-shot request before it expires. + +### Example dishwasher scheduling automations + +The following example uses three Home Assistant automations: + +- Request a Predbat flexible load schedule when the dishwasher is ready. +- Start the dishwasher when Predbat reaches the published **suggested_start**. +- Clear the Predbat schedule if the dishwasher is started manually instead. + +First create a helper to track whether Predbat started the dishwasher. This prevents the manual-start cleanup automation deleting the schedule when Predbat itself starts the appliance: + +```yaml +input_boolean: + dishwasher_started_by_predbat: + name: Dishwasher started by Predbat + icon: mdi:dishwasher +``` + +Replace the switch and sensor entity IDs with the entities for your dishwasher. + +```yaml +alias: Dishwasher - Request Predbat Schedule +description: Request cheapest dishwasher run window from Predbat +triggers: + - trigger: state + entity_id: switch.dishwasher_power + to: "on" +actions: + - delay: + seconds: 20 + - condition: state + entity_id: sensor.dishwasher_operation_state + state: ready + - wait_template: | + {{ is_state('sensor.dishwasher_door', 'closed') }} + timeout: "00:01:00" + continue_on_timeout: false + - action: select.select_option + target: + entity_id: select.predbat_load_forecast_delta_api + data: + option: >- + dishwasher?enabled=true&mode=flexible&end_time=07:00&duration=5&energy=0.7 +mode: single +``` + +The next automation checks every 15 minutes and starts the dishwasher once the current time reaches Predbat's **suggested_start**. Replace **YOUR_DISHWASHER_DEVICE_ID** and the program value with the values for your appliance. + +```yaml +alias: Dishwasher - Start On Predbat Schedule +description: Start dishwasher at Predbat suggested start time +triggers: + - trigger: time_pattern + minutes: /15 +conditions: + - condition: template + value_template: | + {{ + state_attr( + 'binary_sensor.predbat_load_forecast_delta_dishwasher', + 'suggested_start' + ) is not none + }} + - condition: template + value_template: | + {% set start = state_attr( + 'binary_sensor.predbat_load_forecast_delta_dishwasher', + 'suggested_start' + ) %} + {% if start %} + {{ now().timestamp() >= as_timestamp(start) }} + {% else %} + false + {% endif %} + - condition: state + entity_id: input_boolean.dishwasher_started_by_predbat + state: "off" +actions: + - action: input_boolean.turn_on + target: + entity_id: input_boolean.dishwasher_started_by_predbat + - if: + - condition: state + entity_id: switch.dishwasher_power + state: "off" + then: + - action: switch.turn_on + target: + entity_id: switch.dishwasher_power + - delay: + seconds: 20 + - condition: state + entity_id: sensor.dishwasher_operation_state + state: ready + - condition: state + entity_id: sensor.dishwasher_door + state: closed + - action: home_connect.set_program_and_options + data: + device_id: YOUR_DISHWASHER_DEVICE_ID + affects_to: active_program + program: YOUR_DISHWASHER_PROGRAM + - delay: + seconds: 30 + - action: input_boolean.turn_off + target: + entity_id: input_boolean.dishwasher_started_by_predbat +mode: single +``` + +The final automation removes the Predbat request if the dishwasher is started manually before Predbat reaches **suggested_start**: + +```yaml +alias: Dishwasher - Clear Predbat Schedule When Manually Started +description: Remove Predbat schedule if dishwasher starts manually +triggers: + - trigger: state + entity_id: sensor.dishwasher_operation_state + from: ready + to: + - run + - pause +conditions: + - condition: state + entity_id: input_boolean.dishwasher_started_by_predbat + state: "off" + - condition: template + value_template: | + {{ + state_attr( + 'binary_sensor.predbat_load_forecast_delta_dishwasher', + 'suggested_start' + ) is not none + }} +actions: + - action: button.press + target: + entity_id: button.predbat_load_forecast_delta_dishwasher_delete +mode: single +``` + +You can add appliance-specific options, such as quiet or night mode, inside the Home Connect action if your appliance supports them. + Use `enabled=false` in `apps.yaml` to keep static load injection profiles visible but inactive until an automation sends an API forecast with the same name. For advanced cases, you can use **slot_energy** instead when you want to set kWh per Predbat plan slot directly. With the default 30-minute plan interval, `slot_energy: 0.5` adds 0.5kWh to each slot for two hours. From 6bbfb75a4398b115a6b3ede31feb9164a5e3daf4 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 12:36:21 +0200 Subject: [PATCH 14/25] Clamp flexible load selection start --- apps/predbat/fetch.py | 4 +++ .../tests/test_additional_load_forecast.py | 35 +++++++++++++++++++ 2 files changed, 39 insertions(+) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index a7f4bf170..544f5757d 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -427,7 +427,11 @@ def fetch_additional_load_forecast(self, selected_flexible=None): selected_start_minutes = load_item.get("_selected_start_minutes", None) if selected_start_minutes is not None: start_minutes = int(selected_start_minutes) + if requested_start_minutes is not None and start_minutes < requested_start_minutes: + start_minutes = requested_start_minutes end_minutes = start_minutes + int(duration * 60) + if auto_expire: + expires_minutes = end_minutes if auto_expire and expires_minutes is None and end_minutes is not None: expires_minutes = end_minutes if auto_expire and source != "yaml" and expires_minutes is not None: diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 9376bb6b9..0ffbf93c2 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -458,6 +458,40 @@ def test_additional_load_flexible_api_selection_survives_refresh(my_predbat): return failed +def test_additional_load_flexible_api_stale_selection_not_before_requested_start(my_predbat): + """Test stale selected flexible metadata is not published before the frozen requested start.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.minutes_now = 12 * 60 + 30 + my_predbat.plan_interval_minutes = 15 + my_predbat.args["plan_interval_minutes"] = 15 + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.api_select("load_forecast_delta_api", "dishwasher?enabled=true&mode=flexible&end_time=07:00&duration=5.0&energy=0.7") + my_predbat.house_load_additional_forecast_overrides["dishwasher"] = { + "name": "dishwasher", + "_requested_start_minutes": 12 * 60 + 30, + "_selected_start_minutes": 12 * 60, + "_selection_reason": "prediction_metric", + "_candidate_count": 57, + "_selected_metric": -1737.07, + "_baseline_metric": -2007.2, + "_expires_minutes": 17 * 60, + } + my_predbat.refresh_additional_load_forecast_api() + + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + if "T12:30:00" not in forecast.get("requested_start", "") or "T12:30:00" not in forecast.get("suggested_start", ""): + print("ERROR: Flexible API stale selection should not start before requested_start, got {}".format(forecast)) + failed = 1 + if forecast.get("total_energy") != 0.7 or forecast.get("slots") != 20 or "T17:30:00" not in forecast.get("expires_at", ""): + print("ERROR: Flexible API stale selection should keep full shifted load and expiry, got {}".format(forecast)) + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + def test_additional_load_flexible_pending_until_plan(my_predbat): """Test flexible additional load is left for plan-time prediction selection.""" failed = 0 @@ -645,6 +679,7 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_yaml_placeholder_not_published(my_predbat) failed |= test_additional_load_stale_delete_button_no_replan(my_predbat) failed |= test_additional_load_flexible_api_selection_survives_refresh(my_predbat) + failed |= test_additional_load_flexible_api_stale_selection_not_before_requested_start(my_predbat) failed |= test_additional_load_flexible_pending_until_plan(my_predbat) failed |= test_additional_load_flexible_done_by_window(my_predbat) failed |= test_additional_load_flexible_api_omitted_start_is_frozen(my_predbat) From 2da36b8b0a081913655ed716fd67b45414b87882 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 23:04:06 +0200 Subject: [PATCH 15/25] Lock running flexible load forecasts --- apps/predbat/fetch.py | 8 ++++ apps/predbat/plan.py | 4 +- .../tests/test_additional_load_forecast.py | 45 +++++++++++++++++++ 3 files changed, 56 insertions(+), 1 deletion(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 544f5757d..d7e2badfb 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -425,13 +425,17 @@ def fetch_additional_load_forecast(self, selected_flexible=None): weight_total = sum(weights) selected_start_minutes = load_item.get("_selected_start_minutes", None) + selection_locked = False if selected_start_minutes is not None: start_minutes = int(selected_start_minutes) if requested_start_minutes is not None and start_minutes < requested_start_minutes: start_minutes = requested_start_minutes end_minutes = start_minutes + int(duration * 60) + selection_locked = mode == "flexible" and minutes_now_slot >= start_minutes and minutes_now_slot < end_minutes if auto_expire: expires_minutes = end_minutes + if selection_locked and source != "yaml": + self.house_load_additional_forecast_overrides.setdefault(name, {"name": name})["_selection_locked"] = True if auto_expire and expires_minutes is None and end_minutes is not None: expires_minutes = end_minutes if auto_expire and source != "yaml" and expires_minutes is not None: @@ -463,6 +467,7 @@ def fetch_additional_load_forecast(self, selected_flexible=None): "candidate_count": 0, "selected_metric": None, "baseline_metric": None, + "selection_locked": load_item.get("_selection_locked", False) or selection_locked, "source": source, "auto_expire": auto_expire, "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, @@ -497,6 +502,7 @@ def fetch_additional_load_forecast(self, selected_flexible=None): "candidate_count": 0, "selected_metric": None, "baseline_metric": None, + "selection_locked": load_item.get("_selection_locked", False) or selection_locked, "source": source, "auto_expire": auto_expire, "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, @@ -552,6 +558,7 @@ def fetch_additional_load_forecast(self, selected_flexible=None): "candidate_count": load_item.get("_candidate_count", 0), "selected_metric": load_item.get("_selected_metric", None), "baseline_metric": load_item.get("_baseline_metric", None), + "selection_locked": load_item.get("_selection_locked", False) or selection_locked, "source": source, "auto_expire": auto_expire, "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, @@ -594,6 +601,7 @@ def publish_additional_load_forecasts(self): "candidate_count": forecast.get("candidate_count", 0), "selected_metric": forecast.get("selected_metric", None), "baseline_metric": forecast.get("baseline_metric", None), + "selection_locked": forecast.get("selection_locked", False), "source": forecast.get("source", "yaml"), "auto_expire": forecast.get("auto_expire", False), "expires_at": forecast.get("expires_at", None), diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index f65b69b1b..ea669cafe 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -118,7 +118,9 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 """ Select flexible additional load start times using full prediction metric impact. """ - flexible_forecasts = {name: forecast for name, forecast in self.house_load_additional_forecasts.items() if forecast.get("enabled") and forecast.get("mode") == "flexible" and not forecast.get("target_times")} + flexible_forecasts = { + name: forecast for name, forecast in self.house_load_additional_forecasts.items() if forecast.get("enabled") and forecast.get("mode") == "flexible" and not forecast.get("target_times") and not forecast.get("selection_locked", False) + } if not flexible_forecasts: return False, load_minutes_step, load_minutes_step10 diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 0ffbf93c2..02b1e34b3 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -492,6 +492,50 @@ def test_additional_load_flexible_api_stale_selection_not_before_requested_start return failed +def test_additional_load_flexible_api_locks_after_suggested_start(my_predbat): + """Test a selected flexible API forecast locks once the suggested start is reached and then expires.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.minutes_now = 13 * 60 + my_predbat.plan_interval_minutes = 15 + my_predbat.args["plan_interval_minutes"] = 15 + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.api_select("load_forecast_delta_api", "dishwasher?enabled=true&mode=flexible&end_time=07:00&duration=5.0&energy=0.7") + my_predbat.house_load_additional_forecast_overrides["dishwasher"] = { + "name": "dishwasher", + "_requested_start_minutes": 12 * 60 + 30, + "_selected_start_minutes": 12 * 60 + 30, + "_selection_reason": "prediction_metric", + "_candidate_count": 57, + "_selected_metric": -1737.07, + "_baseline_metric": -2007.2, + "_expires_minutes": 17 * 60 + 30, + } + my_predbat.refresh_additional_load_forecast_api() + + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + target_times = forecast.get("target_times", []) + if not forecast.get("selection_locked") or not my_predbat.house_load_additional_forecast_overrides.get("dishwasher", {}).get("_selection_locked"): + print("ERROR: Flexible API forecast should lock after suggested start, got {}".format(forecast)) + failed = 1 + if "T12:30:00" not in forecast.get("suggested_start", "") or forecast.get("slots") != 18 or forecast.get("total_energy") != 0.63: + print("ERROR: Locked flexible API forecast should keep original start with remaining slots, got {}".format(forecast)) + failed = 1 + if not target_times or "T13:00:00" not in target_times[0].get("start", ""): + print("ERROR: Locked flexible API forecast should only publish remaining target slots, got {}".format(target_times)) + failed = 1 + + my_predbat.minutes_now = 17 * 60 + 30 + my_predbat.refresh_additional_load_forecast_api() + if my_predbat.house_load_additional_forecasts or my_predbat.api_select_update("load_forecast_delta_api"): + print("ERROR: Locked flexible API forecast should expire at suggested end, got forecasts {} api {}".format(my_predbat.house_load_additional_forecasts, my_predbat.api_select_update("load_forecast_delta_api"))) + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + def test_additional_load_flexible_pending_until_plan(my_predbat): """Test flexible additional load is left for plan-time prediction selection.""" failed = 0 @@ -680,6 +724,7 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_stale_delete_button_no_replan(my_predbat) failed |= test_additional_load_flexible_api_selection_survives_refresh(my_predbat) failed |= test_additional_load_flexible_api_stale_selection_not_before_requested_start(my_predbat) + failed |= test_additional_load_flexible_api_locks_after_suggested_start(my_predbat) failed |= test_additional_load_flexible_pending_until_plan(my_predbat) failed |= test_additional_load_flexible_done_by_window(my_predbat) failed |= test_additional_load_flexible_api_omitted_start_is_frozen(my_predbat) From 48b3fedf0d644962f68e8a720687cf96b0a3466a Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 23:18:12 +0200 Subject: [PATCH 16/25] Allow flexible load reselection before start --- apps/predbat/fetch.py | 35 +++++++++ apps/predbat/plan.py | 4 +- .../tests/test_additional_load_forecast.py | 77 +++++++++++++++++-- 3 files changed, 108 insertions(+), 8 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index d7e2badfb..d8a4473ea 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -514,6 +514,41 @@ def fetch_additional_load_forecast(self, selected_flexible=None): } continue + if mode == "flexible" and selected_start_minutes is not None and not selection_locked: + forecasts[name] = { + "entity_id": entity_id, + "state": "off", + "target_times": target_times, + "enabled": enabled, + "mode": mode, + "energy": energy_total, + "slot_energy": slot_energy, + "duration": duration, + "weighting": weighting, + "load_mode": load_mode, + "plan_interval_minutes": plan_interval, + "slots": 0, + "total_energy": 0.0, + "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, + "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, + "suggested_start": (self.midnight_utc + timedelta(minutes=start_minutes)).isoformat(), + "suggested_end": (self.midnight_utc + timedelta(minutes=end_minutes)).isoformat(), + "selection_reason": load_item.get("_selection_reason", "prediction_metric"), + "candidate_count": load_item.get("_candidate_count", 0), + "selected_metric": load_item.get("_selected_metric", None), + "baseline_metric": load_item.get("_baseline_metric", None), + "selection_locked": False, + "source": source, + "auto_expire": auto_expire, + "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, + "_requested_start_minutes": requested_start_minutes, + "_requested_end_minutes": requested_end_minutes, + "_periods": periods, + "_weights": weights, + "_weight_total": weight_total, + } + continue + for period in range(periods): slot_start = start_minutes + period * plan_interval slot_end = min(slot_start + plan_interval, end_minutes) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index ea669cafe..398a31fee 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -118,9 +118,7 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 """ Select flexible additional load start times using full prediction metric impact. """ - flexible_forecasts = { - name: forecast for name, forecast in self.house_load_additional_forecasts.items() if forecast.get("enabled") and forecast.get("mode") == "flexible" and not forecast.get("target_times") and not forecast.get("selection_locked", False) - } + flexible_forecasts = {name: forecast for name, forecast in self.house_load_additional_forecasts.items() if forecast.get("enabled") and forecast.get("mode") == "flexible" and not forecast.get("selection_locked", False)} if not flexible_forecasts: return False, load_minutes_step, load_minutes_step10 diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 02b1e34b3..65e04e326 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -447,8 +447,8 @@ def test_additional_load_flexible_api_selection_survives_refresh(my_predbat): if forecast.get("mode") != "flexible" or forecast.get("energy") != 1.2 or forecast.get("duration") != 2.0: print("ERROR: Flexible API forecast should keep command fields after selection refresh, got {}".format(forecast)) failed = 1 - if forecast.get("state") != "on" or forecast.get("slots") != 8 or not forecast.get("target_times"): - print("ERROR: Flexible API forecast should keep selected target slots after refresh, got {}".format(forecast)) + if forecast.get("state") != "off" or forecast.get("slots") != 0 or forecast.get("target_times") or forecast.get("selection_locked"): + print("ERROR: Flexible API forecast before suggested start should publish suggestion only, got {}".format(forecast)) failed = 1 if "T11:15:00" not in forecast.get("suggested_start", "") or "T13:15:00" not in forecast.get("suggested_end", ""): print("ERROR: Flexible API forecast should publish selected window after refresh, got {}".format(forecast)) @@ -458,6 +458,70 @@ def test_additional_load_flexible_api_selection_survives_refresh(my_predbat): return failed +def test_additional_load_flexible_api_reselects_before_suggested_start(my_predbat): + """Test selected flexible API forecasts can be reselected before their suggested start.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.minutes_now = 10 * 60 + my_predbat.plan_interval_minutes = 15 + my_predbat.args["plan_interval_minutes"] = 15 + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.api_select("load_forecast_delta_api", "dishwasher?enabled=true&mode=flexible&start_time=10:00&end_time=22:00&duration=2.0&energy=1.2") + my_predbat.house_load_additional_forecast_overrides["dishwasher"] = { + "name": "dishwasher", + "_selected_start_minutes": 18 * 60, + "_selection_reason": "prediction_metric", + "_candidate_count": 20, + "_selected_metric": 200.0, + "_baseline_metric": 100.0, + "_expires_minutes": 20 * 60, + } + my_predbat.refresh_additional_load_forecast_api() + + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + if "T18:00:00" not in forecast.get("suggested_start", "") or forecast.get("target_times") or forecast.get("selection_locked"): + print("ERROR: Pre-start selected flexible API forecast should be suggestion only before reselection, got {}".format(forecast)) + failed = 1 + + my_predbat.charge_limit_best = [] + my_predbat.charge_window_best = [] + my_predbat.export_window_best = [] + my_predbat.export_limits_best = [] + my_predbat.end_record = my_predbat.forecast_minutes + original_prediction = plan_module.Prediction + + class FakePrediction: + """Fake prediction scores 12:00 as cheapest regardless of the previous suggestion.""" + + def __init__(self, base, pv_step, pv10_step, load_step, load10_step): + """Store load step data.""" + self.load_step = load_step + + def run_prediction(self, charge_limit, charge_window, export_window, export_limits, pv10, end_record): + """Return a metric based on when the injected load appears.""" + first_load_minute = None + for minute, load in self.load_step.items(): + if load > 0: + first_load_minute = my_predbat.minutes_now + minute if first_load_minute is None else min(first_load_minute, my_predbat.minutes_now + minute) + metric = abs(first_load_minute - 12 * 60) if first_load_minute is not None else 1000.0 + return (metric, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0) + + try: + plan_module.Prediction = FakePrediction + selected, load_step, _ = my_predbat.select_flexible_additional_loads({}, {}, {}, {}) + finally: + plan_module.Prediction = original_prediction + + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + if not selected or "T12:00:00" not in forecast.get("suggested_start", "") or forecast.get("target_times") or forecast.get("selection_locked"): + print("ERROR: Pre-start flexible API forecast should reselect to 12:00 without committing target slots, got {}".format(forecast)) + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + def test_additional_load_flexible_api_stale_selection_not_before_requested_start(my_predbat): """Test stale selected flexible metadata is not published before the frozen requested start.""" failed = 0 @@ -660,18 +724,20 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi try: plan_module.Prediction = FakePrediction - selected, _, _ = my_predbat.select_flexible_additional_loads({}, {}, {}, {}) + selected, load_step, _ = my_predbat.select_flexible_additional_loads({}, {}, {}, {}) finally: plan_module.Prediction = original_prediction if not selected: print("ERROR: Flexible prediction metric selection should select a slot") failed = 1 - failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 25 * 60, 0.3, "flexible prediction metric") forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) - if "T01:00:00" not in forecast.get("suggested_start", "") or forecast.get("selection_reason") != "prediction_metric": + if "T01:00:00" not in forecast.get("suggested_start", "") or forecast.get("selection_reason") != "prediction_metric" or forecast.get("target_times"): print("ERROR: Flexible prediction metric should select 01:00, got {}".format(forecast)) failed = 1 + if not load_step: + print("ERROR: Flexible prediction metric should include selected load in returned plan step data") + failed = 1 return failed @@ -723,6 +789,7 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_yaml_placeholder_not_published(my_predbat) failed |= test_additional_load_stale_delete_button_no_replan(my_predbat) failed |= test_additional_load_flexible_api_selection_survives_refresh(my_predbat) + failed |= test_additional_load_flexible_api_reselects_before_suggested_start(my_predbat) failed |= test_additional_load_flexible_api_stale_selection_not_before_requested_start(my_predbat) failed |= test_additional_load_flexible_api_locks_after_suggested_start(my_predbat) failed |= test_additional_load_flexible_pending_until_plan(my_predbat) From c851bf9302f21a8738b6cb9e1523bbb54a7b2fe1 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Thu, 7 May 2026 23:30:14 +0200 Subject: [PATCH 17/25] Fix additional load forecast edge cases --- apps/predbat/fetch.py | 68 +++++++++++++---- apps/predbat/plan.py | 8 +- .../tests/test_additional_load_forecast.py | 74 +++++++++++++++++++ apps/predbat/userinterface.py | 1 + 4 files changed, 133 insertions(+), 18 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index d8a4473ea..9e7d4799e 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -151,10 +151,17 @@ def additional_load_entity_name(self, name): """ Make the binary sensor entity name for a named additional load forecast. """ + safe_name = self.additional_load_safe_name(name) + return "binary_sensor.{}_load_forecast_delta_{}".format(self.prefix, safe_name) + + def additional_load_safe_name(self, name): + """ + Return the Home Assistant-safe suffix for a named additional load forecast. + """ safe_name = re.sub(r"[^a-z0-9_]+", "_", str(name).lower()).strip("_") if not safe_name: safe_name = "unknown" - return "binary_sensor.{}_load_forecast_delta_{}".format(self.prefix, safe_name) + return safe_name def additional_load_delete_entity_name(self, name): """ @@ -164,18 +171,44 @@ def additional_load_delete_entity_name(self, name): def additional_load_name_from_entity(self, entity_id): """ - Return additional load forecast name from a binary sensor or switch entity id. + Return additional load forecast name from a binary sensor or button entity id. """ marker = "_load_forecast_delta_" if entity_id and marker in entity_id: - return entity_id.split(marker, 1)[1].replace("_delete", "") + safe_name = entity_id.split(marker, 1)[1].replace("_delete", "") + for name in list(getattr(self, "house_load_additional_forecasts", {}).keys()) + list(getattr(self, "house_load_additional_forecast_overrides", {}).keys()): + if self.additional_load_safe_name(name) == safe_name: + return str(name) + return self.resolve_additional_load_name(safe_name) return None + def additional_load_command_name(self, value): + """ + Return the forecast name from a load_forecast_delta_api command. + """ + return value.split("?", 1)[0].split("=", 1)[0].replace("[", "").replace("]", "") + + def resolve_additional_load_name(self, name): + """ + Resolve a forecast name or safe entity suffix to the active configured name. + """ + name = str(name) + safe_name = self.additional_load_safe_name(name) + candidates = list(getattr(self, "house_load_additional_forecasts", {}).keys()) + list(getattr(self, "house_load_additional_forecast_overrides", {}).keys()) + item = self.config_index.get("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else None + if item: + values = (item.get("value", "") or "").replace("+", "") + candidates += [self.additional_load_command_name(value) for value in values.split(",") if value and value != "off"] + for candidate in candidates: + if str(candidate) == name or self.additional_load_safe_name(candidate) == safe_name: + return str(candidate) + return name + def delete_additional_load_forecast(self, name): """ Delete a named one-shot additional load forecast. """ - name = str(name) + name = self.resolve_additional_load_name(name) if not self.has_additional_load_api_command(name) and name not in self.house_load_additional_forecast_overrides: self.log("Warn: Ignoring delete for inactive additional load forecast {}".format(name)) self.unpublish_additional_load_name(name) @@ -207,8 +240,8 @@ def has_additional_load_api_command(self, name): for value in values_list: if value == "off": continue - command_name = value.split("?", 1)[0].split("=", 1)[0].replace("[", "").replace("]", "") - if command_name == name: + command_name = self.additional_load_command_name(value) + if command_name == name or self.additional_load_safe_name(command_name) == self.additional_load_safe_name(name): return True return False @@ -226,12 +259,23 @@ def remove_additional_load_api_command(self, name): for value in values_list: if value == "off": continue - command_name = value.split("?", 1)[0].split("=", 1)[0].replace("[", "").replace("]", "") - if command_name != name: + command_name = self.additional_load_command_name(value) + if command_name != name and self.additional_load_safe_name(command_name) != self.additional_load_safe_name(name): new_values_list.append(value) new_value = "+" + ",".join(new_values_list) if new_values_list else "off" self.api_select_update("load_forecast_delta_api", new_value=new_value) + def additional_load_slot_energies(self, energy_total, slot_energy, weights, weight_total, period, slot_minutes, plan_interval): + """ + Return the published slot energy and adjustment rate for one forecast slot. + """ + if energy_total is not None: + target_energy = dp4(energy_total * weights[period] / weight_total) if weight_total else 0.0 + else: + target_energy = dp4(slot_energy * weights[period]) + adjustment_energy = dp4(target_energy * plan_interval / float(slot_minutes)) if slot_minutes else 0.0 + return target_energy, adjustment_energy + def get_additional_load_window(self, load_item, mode, duration, plan_interval, minutes_now_slot): """ Return start/end minutes for fixed or flexible additional load scheduling. @@ -552,17 +596,15 @@ def fetch_additional_load_forecast(self, selected_flexible=None): for period in range(periods): slot_start = start_minutes + period * plan_interval slot_end = min(slot_start + plan_interval, end_minutes) + slot_minutes = slot_end - slot_start if slot_end <= minutes_now_slot: continue if (slot_start - minutes_now_slot) >= self.forecast_minutes: continue - if energy_total is not None: - energy = dp4(energy_total * weights[period] / weight_total) if weight_total else 0.0 - else: - energy = dp4(slot_energy * weights[period]) + energy, adjustment_energy = self.additional_load_slot_energies(energy_total, slot_energy, weights, weight_total, period, slot_minutes, plan_interval) total_energy += energy for minute in range(slot_start, slot_end): - load_adjust[minute] = dp4(load_adjust.get(minute, 0.0) + energy) + load_adjust[minute] = dp4(load_adjust.get(minute, 0.0) + adjustment_energy) target_times.append( { "start": (self.midnight_utc + timedelta(minutes=slot_start)).isoformat(), diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 398a31fee..b23c15c32 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -86,17 +86,15 @@ def additional_load_candidate_profile(self, forecast, start_minutes): for period in range(periods): slot_start = start_minutes + period * plan_interval slot_end = min(slot_start + plan_interval, end_minutes) + slot_minutes = slot_end - slot_start if slot_end <= self.minutes_now: continue if (slot_start - self.minutes_now) >= self.forecast_minutes: continue - if energy_total is not None: - energy = dp4(energy_total * weights[period] / weight_total) if weight_total else 0.0 - else: - energy = dp4(slot_energy * weights[period]) + energy, adjustment_energy = self.additional_load_slot_energies(energy_total, slot_energy, weights, weight_total, period, slot_minutes, plan_interval) total_energy += energy for minute in range(slot_start, slot_end): - load_adjust[minute] = dp4(load_adjust.get(minute, 0.0) + energy) + load_adjust[minute] = dp4(load_adjust.get(minute, 0.0) + adjustment_energy) target_times.append({"start": (self.midnight_utc + timedelta(minutes=slot_start)).isoformat(), "end": (self.midnight_utc + timedelta(minutes=slot_end)).isoformat(), "energy": energy}) return load_adjust, target_times, dp4(total_energy) diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 65e04e326..ba6e35df8 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -188,6 +188,26 @@ def test_additional_load_dishwasher_total_energy_weighting(my_predbat): return failed +def test_additional_load_partial_duration_keeps_total_energy(my_predbat): + """Test partial final slots preserve the configured total energy.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "start_time": "20:00", "duration": 1.25, "energy": 1.2}, + ] + + load_adjust, forecasts = my_predbat.fetch_additional_load_forecast() + failed |= check_slot(load_adjust, 20 * 60, 0.4, "partial duration first slot") + failed |= check_slot(load_adjust, 20 * 60 + 30, 0.4, "partial duration second slot") + failed |= check_slot(load_adjust, 21 * 60, 0.8, "partial duration final half slot") + forecast = forecasts.get("dishwasher", {}) + target_total = round(sum(slot.get("energy", 0.0) for slot in forecast.get("target_times", [])), 4) + if forecast.get("total_energy") != 1.2 or target_total != 1.2: + print("ERROR: Partial duration should publish the configured total energy, got forecast {} target total {}".format(forecast, target_total)) + failed = 1 + return failed + + def test_additional_load_multiple_and_service_override(my_predbat): """Test multiple loads add together and service override updates one named load.""" failed = 0 @@ -216,6 +236,32 @@ def test_additional_load_multiple_and_service_override(my_predbat): return failed +def test_additional_load_sanitized_entity_name_updates_original(my_predbat): + """Test sanitized HA entity names resolve back to the original forecast name.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "Dishwasher Eco", "start_time": "20:00", "duration": 2.0, "energy": 1.2}, + ] + my_predbat.refresh_additional_load_forecast_api() + + service_data = { + "domain": "predbat", + "service": "update_load_forecast_delta", + "service_data": {"entity_id": "binary_sensor.predbat_load_forecast_delta_dishwasher_eco", "start_time": "18:00", "duration": 1.0, "energy": 0.8}, + } + run_async(my_predbat.trigger_callback(service_data)) + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 18 * 60, 0.4, "sanitized service updated original") + failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 20 * 60, 0.0, "sanitized service removed original slot") + if "Dishwasher Eco" not in my_predbat.house_load_additional_forecasts: + print("ERROR: Sanitized service should keep original forecast name, got {}".format(my_predbat.house_load_additional_forecasts)) + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + def test_additional_load_select_api_override(my_predbat): """Test standard HA select API updates a named load forecast.""" failed = 0 @@ -326,6 +372,31 @@ def test_additional_load_delete_button_removes_api_forecast(my_predbat): return failed +def test_additional_load_delete_button_removes_sanitized_api_forecast(my_predbat): + """Test delete buttons remove API forecasts whose names require entity sanitizing.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.api_select("load_forecast_delta_api", "Dishwasher Eco?start_time=20:00&duration=2.0&energy=1.2") + my_predbat.refresh_additional_load_forecast_api() + + service_data = { + "domain": "button", + "service": "press", + "service_data": {"entity_id": "button.predbat_load_forecast_delta_dishwasher_eco_delete"}, + } + run_async(my_predbat.trigger_callback(service_data)) + if my_predbat.api_select_update("load_forecast_delta_api") or my_predbat.house_load_additional_forecasts: + print("ERROR: Sanitized delete button should remove API forecast, got forecasts {} api {}".format(my_predbat.house_load_additional_forecasts, my_predbat.api_select_update("load_forecast_delta_api"))) + failed = 1 + if "button.predbat_load_forecast_delta_dishwasher_eco_delete" in my_predbat.dashboard_values: + print("ERROR: Sanitized delete button should be unpublished") + failed = 1 + + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + def test_additional_load_yaml_does_not_publish_delete_button(my_predbat): """Test YAML forecasts do not get one-shot delete buttons.""" failed = 0 @@ -779,11 +850,14 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_slot_energy_weighting(my_predbat) failed |= test_additional_load_dishwasher_total_energy(my_predbat) failed |= test_additional_load_dishwasher_total_energy_weighting(my_predbat) + failed |= test_additional_load_partial_duration_keeps_total_energy(my_predbat) failed |= test_additional_load_multiple_and_service_override(my_predbat) + failed |= test_additional_load_sanitized_entity_name_updates_original(my_predbat) failed |= test_additional_load_select_api_override(my_predbat) failed |= test_additional_load_select_api_weighting(my_predbat) failed |= test_additional_load_select_event_updates_adjustment(my_predbat) failed |= test_additional_load_delete_button_removes_api_forecast(my_predbat) + failed |= test_additional_load_delete_button_removes_sanitized_api_forecast(my_predbat) failed |= test_additional_load_yaml_does_not_publish_delete_button(my_predbat) failed |= test_additional_load_api_forecast_auto_expires(my_predbat) failed |= test_additional_load_yaml_placeholder_not_published(my_predbat) diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index f983fb1e5..2bfd0424f 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -898,6 +898,7 @@ async def load_forecast_delta_event(self, service, data, kwargs): self.log("Warn: update_load_forecast_delta called without name or target entity_id") self.record_status("Warn: update_load_forecast_delta called without name or target entity_id", had_errors=True) return + name = self.resolve_additional_load_name(name) forecast = {"name": str(name), "_source": "service", "_auto_expire": True} for key in ["start_time", "end_time", "duration", "energy", "slot_energy", "weighting", "enabled", "mode"]: From 93dea2b54696d9c6716f899218551b1dd9b18e0d Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Fri, 8 May 2026 20:57:55 +0200 Subject: [PATCH 18/25] Show suggested additional loads in summary --- apps/predbat/output.py | 33 +++++++++++---- .../tests/test_additional_load_forecast.py | 19 ++++++++- docs/manual-api.md | 40 +++++++++++++++---- 3 files changed, 75 insertions(+), 17 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 2dbf3fe36..4f3196270 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -40,24 +40,41 @@ def additional_load_plan_time(self, timestamp): def get_additional_load_text(self): """ - Return a textual summary of confirmed planned additional load forecasts. + Return a textual summary of planned and suggested additional load forecasts. """ planned_loads = [] for name, forecast in sorted(getattr(self, "house_load_additional_forecasts", {}).items()): + if not forecast.get("enabled", False): + continue target_times = forecast.get("target_times", []) total_energy = forecast.get("total_energy", 0.0) - if not forecast.get("enabled", False) or not target_times or total_energy <= 0: - continue - start = target_times[0].get("start") - end = target_times[-1].get("end") + if target_times and total_energy > 0: + start = target_times[0].get("start") + end = target_times[-1].get("end") + status = "planned" + text = "{} from {} to {} using {:.2f} kWh is planned".format(name, self.additional_load_plan_time(start), self.additional_load_plan_time(end), dp2(total_energy)) if start and end else None + else: + start = forecast.get("suggested_start") + end = forecast.get("suggested_end") + total_energy = forecast.get("energy", 0.0) + if not total_energy: + plan_interval = forecast.get("plan_interval_minutes", self.plan_interval_minutes) + periods = int((int(forecast.get("duration", 0.0) * 60) + plan_interval - 1) / plan_interval) if plan_interval > 0 else 0 + total_energy = forecast.get("slot_energy", 0.0) * periods + status = "suggested" + text = "{} is suggested from {} to {} using {:.2f} kWh".format(name, self.additional_load_plan_time(start), self.additional_load_plan_time(end), dp2(total_energy)) if start and end and total_energy > 0 else None + if not start or not end: continue + if not text: + continue planned_loads.append( { "name": name, "start": start, "end": end, - "text": "{} from {} to {} using {:.2f} kWh".format(name, self.additional_load_plan_time(start), self.additional_load_plan_time(end), dp2(total_energy)), + "status": status, + "text": text, } ) @@ -66,8 +83,8 @@ def get_additional_load_text(self): planned_loads = sorted(planned_loads, key=lambda load: load["start"]) if len(planned_loads) == 1: - return "- Additional load {} is planned.\n".format(planned_loads[0]["text"]) - return "- Additional loads are planned: {}.\n".format("; ".join(load["text"] for load in planned_loads)) + return "- Additional load {}.\n".format(planned_loads[0]["text"]) + return "- Additional loads are planned/suggested: {}.\n".format("; ".join(load["text"] for load in planned_loads)) def publish_car_plan(self): """ diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index ba6e35df8..ba0e04462 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -813,7 +813,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi def test_additional_load_textual_plan_summary(my_predbat): - """Test textual plan includes confirmed additional load forecasts only.""" + """Test textual plan includes confirmed and suggested additional load forecasts only.""" failed = 0 configure_additional_load_test(my_predbat) my_predbat.house_load_additional_forecasts = { @@ -825,13 +825,28 @@ def test_additional_load_textual_plan_summary(my_predbat): {"start": "2026-05-07T10:30:00+02:00", "end": "2026-05-07T11:00:00+02:00", "energy": 0.6}, ], }, + "washer": { + "enabled": True, + "mode": "flexible", + "energy": 0.7, + "slot_energy": 0.0, + "duration": 5.0, + "plan_interval_minutes": 15, + "target_times": [], + "total_energy": 0.0, + "suggested_start": "2026-05-07T20:15:00+02:00", + "suggested_end": "2026-05-08T01:15:00+02:00", + }, "pending": {"enabled": True, "total_energy": 1.0, "target_times": []}, } text = my_predbat.get_additional_load_text() - if "Additional load dishwasher from 10:00 to 11:00 using 1.20 kWh is planned" not in text: + if "dishwasher from 10:00 to 11:00 using 1.20 kWh is planned" not in text: print("ERROR: Textual plan should include planned dishwasher load, got {}".format(text)) failed = 1 + if "washer is suggested from 20:15 to 01:15 using 0.70 kWh" not in text: + print("ERROR: Textual plan should include suggested washer load, got {}".format(text)) + failed = 1 if "pending" in text: print("ERROR: Textual plan should not include pending load, got {}".format(text)) failed = 1 diff --git a/docs/manual-api.md b/docs/manual-api.md index fbabb9bd3..05762dd1e 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -212,11 +212,12 @@ Use **suggested_start** from **binary_sensor.predbat_load_forecast_delta_dishwas ### Example dishwasher scheduling automations -The following example uses three Home Assistant automations: +The following example uses four Home Assistant automations: - Request a Predbat flexible load schedule when the dishwasher is ready. - Start the dishwasher when Predbat reaches the published **suggested_start**. - Clear the Predbat schedule if the dishwasher is started manually instead. +- Reset the Predbat-start helper after the scheduled run is no longer active. First create a helper to track whether Predbat started the dishwasher. This prevents the manual-start cleanup automation deleting the schedule when Predbat itself starts the appliance: @@ -311,15 +312,10 @@ actions: device_id: YOUR_DISHWASHER_DEVICE_ID affects_to: active_program program: YOUR_DISHWASHER_PROGRAM - - delay: - seconds: 30 - - action: input_boolean.turn_off - target: - entity_id: input_boolean.dishwasher_started_by_predbat mode: single ``` -The final automation removes the Predbat request if the dishwasher is started manually before Predbat reaches **suggested_start**: +The next automation removes the Predbat request if the dishwasher is started manually before Predbat reaches **suggested_start**. It only runs when **input_boolean.dishwasher_started_by_predbat** is off, so it will not delete the schedule when the previous automation started the dishwasher for Predbat: ```yaml alias: Dishwasher - Clear Predbat Schedule When Manually Started @@ -330,6 +326,7 @@ triggers: from: ready to: - run + - delayedstart - pause conditions: - condition: state @@ -350,6 +347,35 @@ actions: mode: single ``` +The final automation resets **input_boolean.dishwasher_started_by_predbat** after the Predbat one-shot forecast has disappeared or after the dishwasher returns to a non-running state. This makes the manual-start cleanup automation ready for the next dishwasher cycle without clearing the current Predbat schedule too early: + +```yaml +alias: Dishwasher - Reset Predbat Started Marker +description: Reset Predbat helper after scheduled dishwasher run +triggers: + - trigger: state + entity_id: binary_sensor.predbat_load_forecast_delta_dishwasher + to: + - unavailable + - unknown + - trigger: state + entity_id: sensor.dishwasher_operation_state + to: + - ready + - inactive + - finished + - off +conditions: + - condition: state + entity_id: input_boolean.dishwasher_started_by_predbat + state: "on" +actions: + - action: input_boolean.turn_off + target: + entity_id: input_boolean.dishwasher_started_by_predbat +mode: single +``` + You can add appliance-specific options, such as quiet or night mode, inside the Home Connect action if your appliance supports them. Use `enabled=false` in `apps.yaml` to keep static load injection profiles visible but inactive until an automation sends an API forecast with the same name. From d5e42ef2fad9180c6cafa73862f0a7b596f861a2 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Fri, 8 May 2026 21:07:30 +0200 Subject: [PATCH 19/25] Document dishwasher request guard --- docs/manual-api.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/manual-api.md b/docs/manual-api.md index 05762dd1e..d4ceb9494 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -237,9 +237,16 @@ triggers: - trigger: state entity_id: switch.dishwasher_power to: "on" +conditions: + - condition: state + entity_id: input_boolean.dishwasher_started_by_predbat + state: "off" actions: - delay: seconds: 20 + - condition: state + entity_id: input_boolean.dishwasher_started_by_predbat + state: "off" - condition: state entity_id: sensor.dishwasher_operation_state state: ready From d85576c16c4cf6ee189dbecc3b743e5a7b61ad93 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Fri, 8 May 2026 21:11:30 +0200 Subject: [PATCH 20/25] Clarify running additional load summary --- .cspell/custom-dictionary-workspace.txt | 1 + apps/predbat/output.py | 14 ++++++++++---- .../tests/test_additional_load_forecast.py | 19 +++++++++++++++++++ 3 files changed, 30 insertions(+), 4 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 8fbb4c99d..59302470f 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -76,6 +76,7 @@ dayname daynumber daysymbol dedup +delayedstart dend denorm derating diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 4f3196270..ab8d3630c 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -49,10 +49,16 @@ def get_additional_load_text(self): target_times = forecast.get("target_times", []) total_energy = forecast.get("total_energy", 0.0) if target_times and total_energy > 0: - start = target_times[0].get("start") - end = target_times[-1].get("end") - status = "planned" - text = "{} from {} to {} using {:.2f} kWh is planned".format(name, self.additional_load_plan_time(start), self.additional_load_plan_time(end), dp2(total_energy)) if start and end else None + running = forecast.get("selection_locked", False) + start = forecast.get("suggested_start") if running and forecast.get("suggested_start") else target_times[0].get("start") + end = forecast.get("suggested_end") if running and forecast.get("suggested_end") else target_times[-1].get("end") + if running: + total_energy = forecast.get("energy", total_energy) or total_energy + status = "running" + text = "{} is running from {} to {} using {:.2f} kWh".format(name, self.additional_load_plan_time(start), self.additional_load_plan_time(end), dp2(total_energy)) if start and end else None + else: + status = "planned" + text = "{} from {} to {} using {:.2f} kWh is planned".format(name, self.additional_load_plan_time(start), self.additional_load_plan_time(end), dp2(total_energy)) if start and end else None else: start = forecast.get("suggested_start") end = forecast.get("suggested_end") diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index ba0e04462..86ca82649 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -837,6 +837,22 @@ def test_additional_load_textual_plan_summary(my_predbat): "suggested_start": "2026-05-07T20:15:00+02:00", "suggested_end": "2026-05-08T01:15:00+02:00", }, + "dryer": { + "enabled": True, + "mode": "flexible", + "energy": 0.9, + "slot_energy": 0.0, + "duration": 3.0, + "plan_interval_minutes": 15, + "selection_locked": True, + "target_times": [ + {"start": "2026-05-07T21:15:00+02:00", "end": "2026-05-07T21:30:00+02:00", "energy": 0.075}, + {"start": "2026-05-07T21:30:00+02:00", "end": "2026-05-08T00:00:00+02:00", "energy": 0.825}, + ], + "total_energy": 0.825, + "suggested_start": "2026-05-07T21:00:00+02:00", + "suggested_end": "2026-05-08T00:00:00+02:00", + }, "pending": {"enabled": True, "total_energy": 1.0, "target_times": []}, } @@ -847,6 +863,9 @@ def test_additional_load_textual_plan_summary(my_predbat): if "washer is suggested from 20:15 to 01:15 using 0.70 kWh" not in text: print("ERROR: Textual plan should include suggested washer load, got {}".format(text)) failed = 1 + if "dryer is running from 21:00 to 00:00 using 0.90 kWh" not in text: + print("ERROR: Textual plan should include running dryer load, got {}".format(text)) + failed = 1 if "pending" in text: print("ERROR: Textual plan should not include pending load, got {}".format(text)) failed = 1 From 16a87673aa936d8e2bbed609af757a77d05817eb Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Sat, 9 May 2026 08:22:40 +0200 Subject: [PATCH 21/25] Persist dynamic load forecast metadata --- apps/predbat/fetch.py | 124 +++++++++++++++++- apps/predbat/plan.py | 14 ++ .../tests/test_additional_load_forecast.py | 86 ++++++++++++ apps/predbat/userinterface.py | 10 +- docs/manual-api.md | 2 + 5 files changed, 232 insertions(+), 4 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 9e7d4799e..7193478b4 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -188,6 +188,97 @@ def additional_load_command_name(self, value): """ return value.split("?", 1)[0].split("=", 1)[0].replace("[", "").replace("]", "") + def additional_load_command_args(self, value): + """ + Return a load_forecast_delta_api command name and query arguments. + """ + value = value.replace("[", "").replace("]", "") + if "?" not in value: + return self.additional_load_command_name(value), {} + name, command_args = value.split("?", 1) + args = {} + for arg in command_args.split("&"): + arg_split = arg.split("=", 1) + if len(arg_split) > 1: + args[arg_split[0]] = arg_split[1] + else: + args[arg_split[0]] = True + return name, args + + def additional_load_build_api_command(self, name, args): + """ + Build a load_forecast_delta_api command from a name and arguments. + """ + return "{}?{}".format(name, "&".join("{}={}".format(key, value) if value is not True else key for key, value in args.items())) + + def additional_load_minutes_to_stamp(self, minutes): + """ + Convert forecast minutes from midnight into a durable timestamp string. + """ + return str(int((self.midnight_utc + timedelta(minutes=int(minutes))).timestamp())) + + def additional_load_stamp_to_minutes(self, stamp): + """ + Convert a durable timestamp string into forecast minutes from the current midnight. + """ + try: + stamp_datetime = datetime.fromtimestamp(int(stamp), tz=self.midnight_utc.tzinfo) if str(stamp).isdigit() else datetime.fromisoformat(str(stamp)) + except (ValueError, TypeError): + return None + return int((stamp_datetime - self.midnight_utc).total_seconds() / 60) + + def additional_load_api_metadata(self, name): + """ + Return persisted hidden metadata for a stored load_forecast_delta_api command. + """ + item = self.config_index.get("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else None + if not item: + return {} + values = (item.get("value", "") or "").replace("+", "") + for value in values.split(",") if values else []: + command_name, args = self.additional_load_command_args(value) + if command_name == name or self.additional_load_safe_name(command_name) == self.additional_load_safe_name(name): + return {key: value for key, value in args.items() if str(key).startswith("_")} + return {} + + def preserve_additional_load_api_metadata(self, value): + """ + Preserve hidden one-shot metadata when an active API command is sent again. + """ + name, args = self.additional_load_command_args(value) + metadata = self.additional_load_api_metadata(name) + if not metadata: + return value + for key, metadata_value in metadata.items(): + args.setdefault(key, metadata_value) + return self.additional_load_build_api_command(name, args) + + def update_additional_load_api_command_metadata(self, name, metadata): + """ + Persist one-shot runtime metadata into the stored API selector command. + """ + item = self.config_index.get("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else None + if not item: + return + values = (item.get("value", "") or "").replace("+", "") + if not values: + return + changed = False + new_values = [] + for value in values.split(","): + if value == "off": + continue + command_name, args = self.additional_load_command_args(value) + if command_name == name or self.additional_load_safe_name(command_name) == self.additional_load_safe_name(name): + for key, metadata_value in metadata.items(): + if metadata_value is not None and args.get(key) != str(metadata_value): + args[key] = str(metadata_value) + changed = True + value = self.additional_load_build_api_command(command_name, args) + new_values.append(value) + if changed: + self.api_select_update("load_forecast_delta_api", new_value="+" + ",".join(new_values) if new_values else "off") + def resolve_additional_load_name(self, name): """ Resolve a forecast name or safe entity suffix to the active configured name. @@ -340,14 +431,36 @@ def parse_additional_load_api_command(self, api_command): override[arg_split[0]] = arg_split[1] else: override[arg_split[0]] = True + requested_start_minutes = self.additional_load_stamp_to_minutes(override.get("_requested_start", None)) if "_requested_start" in override else None + selected_start_minutes = self.additional_load_stamp_to_minutes(override.get("_selected_start", None)) if "_selected_start" in override else None + expires_minutes = self.additional_load_stamp_to_minutes(override.get("_expires_at", None)) if "_expires_at" in override else None + if requested_start_minutes is not None: + override["_requested_start_minutes"] = requested_start_minutes + if selected_start_minutes is not None: + override["_selected_start_minutes"] = selected_start_minutes + if expires_minutes is not None: + override["_expires_minutes"] = expires_minutes + for key in ["_candidate_count"]: + if key in override: + try: + override[key] = int(override[key]) + except (ValueError, TypeError): + override.pop(key, None) + for key in ["_selected_metric", "_baseline_metric"]: + if key in override: + try: + override[key] = float(override[key]) + except (ValueError, TypeError): + override.pop(key, None) if "start_time" not in override: existing_override = self.house_load_additional_forecast_overrides.get(str(name), {}) - requested_start_minutes = existing_override.get("_requested_start_minutes", None) + requested_start_minutes = override.get("_requested_start_minutes", existing_override.get("_requested_start_minutes", None)) if requested_start_minutes is None: plan_interval = self.get_arg("plan_interval_minutes", 30) requested_start_minutes = int(self.minutes_now / plan_interval) * plan_interval override["_requested_start_minutes"] = requested_start_minutes self.house_load_additional_forecast_overrides.setdefault(str(name), {"name": str(name)})["_requested_start_minutes"] = requested_start_minutes + self.update_additional_load_api_command_metadata(str(name), {"_requested_start": self.additional_load_minutes_to_stamp(requested_start_minutes)}) return override def expire_additional_load_api_commands(self): @@ -356,11 +469,18 @@ def expire_additional_load_api_commands(self): """ expired_names = [] minutes_now_slot = int(self.minutes_now / self.get_arg("plan_interval_minutes", 30)) * self.get_arg("plan_interval_minutes", 30) + item = self.config_index.get("load_forecast_delta_api") if "load_forecast_delta_api" in self.config_index else None + values = (item.get("value", "") or "").replace("+", "") if item else "" + for value in values.split(",") if values else []: + name, args = self.additional_load_command_args(value) + expires_minutes = self.additional_load_stamp_to_minutes(args.get("_expires_at", None)) if "_expires_at" in args else None + if expires_minutes is not None and expires_minutes <= minutes_now_slot: + expired_names.append(name) for name, override in list(self.house_load_additional_forecast_overrides.items()): expires_minutes = override.get("_expires_minutes", None) if expires_minutes is not None and expires_minutes <= minutes_now_slot: expired_names.append(name) - for name in expired_names: + for name in set(expired_names): self.log("Expired additional load forecast {}".format(name)) self.house_load_additional_forecast_overrides.pop(name, None) self.remove_additional_load_api_command(name) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index b23c15c32..b6069f89a 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -172,6 +172,20 @@ def select_flexible_additional_loads(self, load_minutes_step, load_minutes_step1 } if forecast.get("auto_expire", False): self.house_load_additional_forecast_overrides[name] = {"name": name, **selected_flexible[name]} + self.update_additional_load_api_command_metadata( + name, + { + "_requested_start": self.additional_load_minutes_to_stamp(start_minutes), + "_requested_end": self.additional_load_minutes_to_stamp(end_minutes), + "_selected_start": self.additional_load_minutes_to_stamp(best_start), + "_selected_end": self.additional_load_minutes_to_stamp(best_start + duration_minutes), + "_expires_at": self.additional_load_minutes_to_stamp(best_start + duration_minutes), + "_selection_reason": "prediction_metric", + "_candidate_count": candidate_count, + "_selected_metric": dp2(best_metric) if best_metric is not None else None, + "_baseline_metric": dp2(baseline_metric), + }, + ) self.log("Flexible additional load {} selected {}-{} using prediction metric {} from {} candidates".format(name, self.time_abs_str(best_start), self.time_abs_str(best_start + duration_minutes), dp2(best_metric), candidate_count)) if not selected_flexible: diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 86ca82649..98329d8f7 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -10,6 +10,8 @@ """Tests for named additional house load forecasts.""" +from datetime import timedelta + import plan as plan_module from tests.test_infra import run_async @@ -671,6 +673,88 @@ def test_additional_load_flexible_api_locks_after_suggested_start(my_predbat): return failed +def test_additional_load_flexible_api_metadata_survives_restart(my_predbat): + """Test omitted start_time and selected flexible metadata survive API command reparse.""" + failed = 0 + configure_additional_load_test(my_predbat) + original_midnight = my_predbat.midnight_utc + my_predbat.minutes_now = 20 * 60 + 45 + my_predbat.plan_interval_minutes = 15 + my_predbat.args["plan_interval_minutes"] = 15 + my_predbat.args["house_load_additional_forecast"] = [] + my_predbat.api_select("load_forecast_delta_api", "dishwasher?enabled=true&mode=flexible&end_time=07:00&duration=5.0&energy=0.7") + my_predbat.refresh_additional_load_forecast_api() + my_predbat.update_additional_load_api_command_metadata( + "dishwasher", + { + "_selected_start": my_predbat.additional_load_minutes_to_stamp(21 * 60), + "_selected_end": my_predbat.additional_load_minutes_to_stamp(26 * 60), + "_expires_at": my_predbat.additional_load_minutes_to_stamp(26 * 60), + }, + ) + + api_command = my_predbat.api_select_update("load_forecast_delta_api")[0] + if "_requested_start=" not in api_command or "_selected_start=" not in api_command or "_expires_at=" not in api_command: + print("ERROR: API command should persist requested, selected, and expiry metadata, got {}".format(api_command)) + failed = 1 + + my_predbat.house_load_additional_forecast_overrides = {} + my_predbat.midnight_utc = original_midnight + timedelta(days=1) + my_predbat.minutes_now = 30 + my_predbat.refresh_additional_load_forecast_api() + + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + if not forecast.get("selection_locked") or "T21:00:00" not in forecast.get("suggested_start", "") or "T02:00:00" not in forecast.get("suggested_end", ""): + print("ERROR: Reparsed API command should restore old locked selection after restart, got {}".format(forecast)) + failed = 1 + + my_predbat.minutes_now = 20 * 60 + 45 + my_predbat.refresh_additional_load_forecast_api() + if my_predbat.house_load_additional_forecasts or my_predbat.api_select_update("load_forecast_delta_api"): + print("ERROR: Reparsed expired API command should be removed, got forecasts {} api {}".format(my_predbat.house_load_additional_forecasts, my_predbat.api_select_update("load_forecast_delta_api"))) + failed = 1 + + my_predbat.midnight_utc = original_midnight + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + +def test_additional_load_flexible_api_repeat_preserves_metadata(my_predbat): + """Test repeating an active API command preserves existing one-shot metadata.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.minutes_now = 20 * 60 + 45 + my_predbat.plan_interval_minutes = 15 + my_predbat.args["plan_interval_minutes"] = 15 + my_predbat.args["house_load_additional_forecast"] = [] + api_command = "dishwasher?enabled=true&mode=flexible&end_time=07:00&duration=5.0&energy=0.7" + my_predbat.api_select("load_forecast_delta_api", api_command) + my_predbat.refresh_additional_load_forecast_api() + my_predbat.update_additional_load_api_command_metadata( + "dishwasher", + { + "_selected_start": my_predbat.additional_load_minutes_to_stamp(21 * 60), + "_selected_end": my_predbat.additional_load_minutes_to_stamp(26 * 60), + "_expires_at": my_predbat.additional_load_minutes_to_stamp(26 * 60), + }, + ) + + my_predbat.minutes_now = 21 * 60 + 15 + my_predbat.api_select("load_forecast_delta_api", api_command) + my_predbat.refresh_additional_load_forecast_api() + + stored_command = my_predbat.api_select_update("load_forecast_delta_api")[0] + forecast = my_predbat.house_load_additional_forecasts.get("dishwasher", {}) + if "_selected_start=" not in stored_command or "T21:00:00" not in forecast.get("suggested_start", "") or not forecast.get("selection_locked"): + print("ERROR: Repeated active API command should preserve selected metadata, command {} forecast {}".format(stored_command, forecast)) + failed = 1 + + my_predbat.api_select("load_forecast_delta_api", "off") + my_predbat.house_load_additional_forecast_overrides = {} + return failed + + def test_additional_load_flexible_pending_until_plan(my_predbat): """Test flexible additional load is left for plan-time prediction selection.""" failed = 0 @@ -900,6 +984,8 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_flexible_api_reselects_before_suggested_start(my_predbat) failed |= test_additional_load_flexible_api_stale_selection_not_before_requested_start(my_predbat) failed |= test_additional_load_flexible_api_locks_after_suggested_start(my_predbat) + failed |= test_additional_load_flexible_api_metadata_survives_restart(my_predbat) + failed |= test_additional_load_flexible_api_repeat_preserves_metadata(my_predbat) failed |= test_additional_load_flexible_pending_until_plan(my_predbat) failed |= test_additional_load_flexible_done_by_window(my_predbat) failed |= test_additional_load_flexible_api_omitted_start_is_frozen(my_predbat) diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 2bfd0424f..407077cf5 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -1226,7 +1226,10 @@ def manual_select(self, config_item, value): return if config_item == "load_forecast_delta_api" and value != "off": name = value.split("?", 1)[0].split("=", 1)[0].replace("[", "").replace("]", "") - self.house_load_additional_forecast_overrides.pop(name, None) + if "[" not in value and self.has_additional_load_api_command(name): + value = self.preserve_additional_load_api_metadata(value) + else: + self.house_load_additional_forecast_overrides.pop(name, None) values = item.get("value", "") if not values: values = "" @@ -1311,7 +1314,10 @@ def api_select(self, config_item, value): return if config_item == "load_forecast_delta_api" and value != "off" and "[" not in value: name = value.split("?", 1)[0].split("=", 1)[0] - self.house_load_additional_forecast_overrides.pop(name, None) + if self.has_additional_load_api_command(name): + value = self.preserve_additional_load_api_metadata(value) + else: + self.house_load_additional_forecast_overrides.pop(name, None) values = item.get("value", "") if not values: values = "" diff --git a/docs/manual-api.md b/docs/manual-api.md index d4ceb9494..7de97353f 100644 --- a/docs/manual-api.md +++ b/docs/manual-api.md @@ -174,6 +174,8 @@ The **energy** value is the total kWh across the full duration. Predbat divides Forecasts created through **select.predbat_load_forecast_delta_api** are one-shot dynamic loads. Predbat publishes a delete button for each of these forecasts, for example **button.predbat_load_forecast_delta_dishwasher_delete**, and automatically removes the forecast after its finish time. If you want the same forecast again, send the select command again. +While a one-shot forecast is active, Predbat preserves hidden request and selected-window metadata in the stored selector option so the schedule survives Home Assistant or Predbat restarts. Sending the same command again while it is still active does not reset the frozen request time or move a locked/running forecast. To force a fresh schedule, press the forecast delete button first and then send the request again. + If the appliance can run at any time before a deadline, send `mode=flexible`. For flexible loads, `start_time` is the earliest allowed start and `end_time` means done by. Predbat chooses the best block using the full prediction metric, so the selection considers solar, battery state, import/export rates, losses, and the current plan rather than just the import rate: ```yaml From 7fea30588ab83297ddbf28170603ce5f5d9e503f Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Mon, 11 May 2026 10:28:39 +0200 Subject: [PATCH 22/25] Fix additional load forecast cleanup --- apps/predbat/ha.py | 6 ++++-- apps/predbat/userinterface.py | 4 +--- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/apps/predbat/ha.py b/apps/predbat/ha.py index 307fc52c0..e37246903 100644 --- a/apps/predbat/ha.py +++ b/apps/predbat/ha.py @@ -894,10 +894,12 @@ def delete_state(self, entity_id): """ Delete a state from Home Assistant. """ - self.db_mirror_list.pop(entity_id, None) - self.state_data.pop(entity_id.lower(), None) + entity_id_lower = entity_id.lower() + 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) + return True def api_call(self, endpoint, data_in=None, post=False, delete=False, core=True, silent=False): """ diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index 407077cf5..cca5a12ae 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -891,9 +891,7 @@ async def load_forecast_delta_event(self, service, data, kwargs): entity_id = entity_id[0] if entity_id else None name = service_data.get("name", None) if not name and entity_id: - marker = "_load_forecast_delta_" - if marker in entity_id: - name = entity_id.split(marker, 1)[1] + name = self.additional_load_name_from_entity(entity_id) if not name: self.log("Warn: update_load_forecast_delta called without name or target entity_id") self.record_status("Warn: update_load_forecast_delta called without name or target entity_id", had_errors=True) From ccdb97c2578eeedfabe4b2ce007a4d7777aa2707 Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Mon, 11 May 2026 10:30:12 +0200 Subject: [PATCH 23/25] Handle mixed-case state cleanup --- apps/predbat/ha.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/predbat/ha.py b/apps/predbat/ha.py index e37246903..a90161c77 100644 --- a/apps/predbat/ha.py +++ b/apps/predbat/ha.py @@ -895,6 +895,7 @@ 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: From a471d5497ac6295bc5aa22d02ab14309cde5addf Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Mon, 11 May 2026 15:08:49 +0200 Subject: [PATCH 24/25] Simplify additional load forecast API --- apps/predbat/fetch.py | 314 ++++++++++-------- .../tests/test_additional_load_forecast.py | 46 +-- apps/predbat/userinterface.py | 33 -- 3 files changed, 185 insertions(+), 208 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 7193478b4..a521016b5 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -227,6 +227,82 @@ def additional_load_stamp_to_minutes(self, stamp): return None return int((stamp_datetime - self.midnight_utc).total_seconds() / 60) + def additional_load_minutes_to_iso(self, minutes): + """ + Convert forecast minutes from midnight into an ISO timestamp. + """ + return (self.midnight_utc + timedelta(minutes=minutes)).isoformat() if minutes is not None else None + + def additional_load_forecast_record( + self, + entity_id, + enabled, + mode, + energy_total, + slot_energy, + duration, + weighting, + load_mode, + plan_interval, + requested_start_minutes, + requested_end_minutes, + periods, + weights, + weight_total, + source, + auto_expire, + expires_minutes, + target_times=None, + total_energy=0.0, + suggested_start_minutes=None, + suggested_end_minutes=None, + selection_reason=None, + candidate_count=0, + selected_metric=None, + baseline_metric=None, + selection_locked=False, + state=None, + ): + """ + Build the published and internal metadata for one additional load forecast. + """ + if target_times is None: + target_times = [] + if state is None: + state = "on" if target_times else "off" + return { + "entity_id": entity_id, + "state": state, + "target_times": target_times, + "enabled": enabled, + "mode": mode, + "energy": energy_total, + "slot_energy": slot_energy, + "duration": duration, + "weighting": weighting, + "load_mode": load_mode, + "plan_interval_minutes": plan_interval, + "slots": len(target_times), + "total_energy": dp4(total_energy), + "requested_start": self.additional_load_minutes_to_iso(requested_start_minutes), + "requested_end": self.additional_load_minutes_to_iso(requested_end_minutes), + "suggested_start": self.additional_load_minutes_to_iso(suggested_start_minutes), + "suggested_end": self.additional_load_minutes_to_iso(suggested_end_minutes), + "selection_reason": selection_reason, + "candidate_count": candidate_count, + "selected_metric": selected_metric, + "baseline_metric": baseline_metric, + "selection_locked": selection_locked, + "source": source, + "auto_expire": auto_expire, + "expires_at": self.additional_load_minutes_to_iso(expires_minutes), + "_requested_start_minutes": requested_start_minutes, + "_requested_end_minutes": requested_end_minutes, + "_periods": periods, + "_weights": weights, + "_weight_total": weight_total, + } + def additional_load_api_metadata(self, name): """ Return persisted hidden metadata for a stored load_forecast_delta_api command. @@ -419,18 +495,13 @@ def parse_additional_load_api_command(self, api_command): self.log("Warn: Bad load_forecast_delta_api command {}, expected name?start_time=...&duration=...".format(api_command)) return None - name, command_args = api_command.split("?", 1) + name, command_args = self.additional_load_command_args(api_command) if not name: self.log("Warn: Bad load_forecast_delta_api command {}, missing name".format(api_command)) return None override = {"name": name, "_source": "api", "_auto_expire": True} - for arg in command_args.split("&"): - arg_split = arg.split("=", 1) - if len(arg_split) > 1: - override[arg_split[0]] = arg_split[1] - else: - override[arg_split[0]] = True + override.update(command_args) requested_start_minutes = self.additional_load_stamp_to_minutes(override.get("_requested_start", None)) if "_requested_start" in override else None selected_start_minutes = self.additional_load_stamp_to_minutes(override.get("_selected_start", None)) if "_selected_start" in override else None expires_minutes = self.additional_load_stamp_to_minutes(override.get("_expires_at", None)) if "_expires_at" in override else None @@ -609,108 +680,81 @@ def fetch_additional_load_forecast(self, selected_flexible=None): continue if not enabled or start_minutes is None or (energy_total is None and slot_energy == 0) or (energy_total == 0) or duration == 0 or end_minutes is None: - forecasts[name] = { - "entity_id": entity_id, - "state": "off", - "target_times": target_times, - "enabled": enabled, - "mode": mode, - "energy": energy_total, - "slot_energy": slot_energy, - "duration": duration, - "weighting": weighting, - "load_mode": load_mode, - "plan_interval_minutes": plan_interval, - "slots": 0, - "total_energy": 0.0, - "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, - "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, - "suggested_start": None, - "suggested_end": None, - "selection_reason": None, - "candidate_count": 0, - "selected_metric": None, - "baseline_metric": None, - "selection_locked": load_item.get("_selection_locked", False) or selection_locked, - "source": source, - "auto_expire": auto_expire, - "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, - "_requested_start_minutes": requested_start_minutes, - "_requested_end_minutes": requested_end_minutes, - "_periods": periods, - "_weights": weights, - "_weight_total": weight_total, - } + forecasts[name] = self.additional_load_forecast_record( + entity_id, + enabled, + mode, + energy_total, + slot_energy, + duration, + weighting, + load_mode, + plan_interval, + requested_start_minutes, + requested_end_minutes, + periods, + weights, + weight_total, + source, + auto_expire, + expires_minutes, + selection_locked=load_item.get("_selection_locked", False) or selection_locked, + state="off", + ) continue if mode == "flexible" and selected_start_minutes is None: - forecasts[name] = { - "entity_id": entity_id, - "state": "off", - "target_times": target_times, - "enabled": enabled, - "mode": mode, - "energy": energy_total, - "slot_energy": slot_energy, - "duration": duration, - "weighting": weighting, - "load_mode": load_mode, - "plan_interval_minutes": plan_interval, - "slots": 0, - "total_energy": 0.0, - "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, - "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, - "suggested_start": None, - "suggested_end": None, - "selection_reason": "pending_prediction_metric", - "candidate_count": 0, - "selected_metric": None, - "baseline_metric": None, - "selection_locked": load_item.get("_selection_locked", False) or selection_locked, - "source": source, - "auto_expire": auto_expire, - "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, - "_requested_start_minutes": requested_start_minutes, - "_requested_end_minutes": requested_end_minutes, - "_periods": periods, - "_weights": weights, - "_weight_total": weight_total, - } + forecasts[name] = self.additional_load_forecast_record( + entity_id, + enabled, + mode, + energy_total, + slot_energy, + duration, + weighting, + load_mode, + plan_interval, + requested_start_minutes, + requested_end_minutes, + periods, + weights, + weight_total, + source, + auto_expire, + expires_minutes, + selection_reason="pending_prediction_metric", + selection_locked=load_item.get("_selection_locked", False) or selection_locked, + state="off", + ) continue if mode == "flexible" and selected_start_minutes is not None and not selection_locked: - forecasts[name] = { - "entity_id": entity_id, - "state": "off", - "target_times": target_times, - "enabled": enabled, - "mode": mode, - "energy": energy_total, - "slot_energy": slot_energy, - "duration": duration, - "weighting": weighting, - "load_mode": load_mode, - "plan_interval_minutes": plan_interval, - "slots": 0, - "total_energy": 0.0, - "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, - "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, - "suggested_start": (self.midnight_utc + timedelta(minutes=start_minutes)).isoformat(), - "suggested_end": (self.midnight_utc + timedelta(minutes=end_minutes)).isoformat(), - "selection_reason": load_item.get("_selection_reason", "prediction_metric"), - "candidate_count": load_item.get("_candidate_count", 0), - "selected_metric": load_item.get("_selected_metric", None), - "baseline_metric": load_item.get("_baseline_metric", None), - "selection_locked": False, - "source": source, - "auto_expire": auto_expire, - "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, - "_requested_start_minutes": requested_start_minutes, - "_requested_end_minutes": requested_end_minutes, - "_periods": periods, - "_weights": weights, - "_weight_total": weight_total, - } + forecasts[name] = self.additional_load_forecast_record( + entity_id, + enabled, + mode, + energy_total, + slot_energy, + duration, + weighting, + load_mode, + plan_interval, + requested_start_minutes, + requested_end_minutes, + periods, + weights, + weight_total, + source, + auto_expire, + expires_minutes, + suggested_start_minutes=start_minutes, + suggested_end_minutes=end_minutes, + selection_reason=load_item.get("_selection_reason", "prediction_metric"), + candidate_count=load_item.get("_candidate_count", 0), + selected_metric=load_item.get("_selected_metric", None), + baseline_metric=load_item.get("_baseline_metric", None), + state="off", + ) continue for period in range(periods): @@ -727,44 +771,40 @@ def fetch_additional_load_forecast(self, selected_flexible=None): load_adjust[minute] = dp4(load_adjust.get(minute, 0.0) + adjustment_energy) target_times.append( { - "start": (self.midnight_utc + timedelta(minutes=slot_start)).isoformat(), - "end": (self.midnight_utc + timedelta(minutes=slot_end)).isoformat(), + "start": self.additional_load_minutes_to_iso(slot_start), + "end": self.additional_load_minutes_to_iso(slot_end), "energy": energy, } ) - forecasts[name] = { - "entity_id": entity_id, - "state": "on" if target_times else "off", - "target_times": target_times, - "enabled": enabled, - "mode": mode, - "energy": energy_total, - "slot_energy": slot_energy, - "duration": duration, - "weighting": weighting, - "load_mode": load_mode, - "plan_interval_minutes": plan_interval, - "slots": len(target_times), - "total_energy": dp4(total_energy), - "requested_start": (self.midnight_utc + timedelta(minutes=requested_start_minutes)).isoformat() if requested_start_minutes is not None else None, - "requested_end": (self.midnight_utc + timedelta(minutes=requested_end_minutes)).isoformat() if requested_end_minutes is not None else None, - "suggested_start": (self.midnight_utc + timedelta(minutes=start_minutes)).isoformat() if mode == "flexible" and target_times else None, - "suggested_end": (self.midnight_utc + timedelta(minutes=end_minutes)).isoformat() if mode == "flexible" and target_times else None, - "selection_reason": load_item.get("_selection_reason", "prediction_metric" if mode == "flexible" and target_times else None), - "candidate_count": load_item.get("_candidate_count", 0), - "selected_metric": load_item.get("_selected_metric", None), - "baseline_metric": load_item.get("_baseline_metric", None), - "selection_locked": load_item.get("_selection_locked", False) or selection_locked, - "source": source, - "auto_expire": auto_expire, - "expires_at": (self.midnight_utc + timedelta(minutes=expires_minutes)).isoformat() if expires_minutes is not None else None, - "_requested_start_minutes": requested_start_minutes, - "_requested_end_minutes": requested_end_minutes, - "_periods": periods, - "_weights": weights, - "_weight_total": weight_total, - } + forecasts[name] = self.additional_load_forecast_record( + entity_id, + enabled, + mode, + energy_total, + slot_energy, + duration, + weighting, + load_mode, + plan_interval, + requested_start_minutes, + requested_end_minutes, + periods, + weights, + weight_total, + source, + auto_expire, + expires_minutes, + target_times=target_times, + total_energy=total_energy, + suggested_start_minutes=start_minutes if mode == "flexible" and target_times else None, + suggested_end_minutes=end_minutes if mode == "flexible" and target_times else None, + selection_reason=load_item.get("_selection_reason", "prediction_metric" if mode == "flexible" and target_times else None), + candidate_count=load_item.get("_candidate_count", 0), + selected_metric=load_item.get("_selected_metric", None), + baseline_metric=load_item.get("_baseline_metric", None), + selection_locked=load_item.get("_selection_locked", False) or selection_locked, + ) return load_adjust, forecasts diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 98329d8f7..079dd371c 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -210,8 +210,8 @@ def test_additional_load_partial_duration_keeps_total_energy(my_predbat): return failed -def test_additional_load_multiple_and_service_override(my_predbat): - """Test multiple loads add together and service override updates one named load.""" +def test_additional_load_multiple_and_api_override(my_predbat): + """Test multiple loads add together and API override updates one named load.""" failed = 0 configure_additional_load_test(my_predbat) my_predbat.args["house_load_additional_forecast"] = [ @@ -224,41 +224,12 @@ def test_additional_load_multiple_and_service_override(my_predbat): failed |= check_slot(load_adjust, 20 * 60 + 30, 0.75, "multiple loads overlap") failed |= check_slot(load_adjust, 21 * 60, 0.75, "multiple loads overlap") - service_data = { - "domain": "predbat", - "service": "update_load_forecast_delta", - "service_data": {"entity_id": "binary_sensor.predbat_load_forecast_delta_dishwasher", "start_time": "18:00", "duration": 1.0, "energy": 0.8}, - } - run_async(my_predbat.trigger_callback(service_data)) + my_predbat.api_select("load_forecast_delta_api", "dishwasher?start_time=18:00&duration=1.0&energy=0.8") load_adjust, _ = my_predbat.fetch_additional_load_forecast() - failed |= check_slot(load_adjust, 18 * 60, 0.4, "service override dishwasher") - failed |= check_slot(load_adjust, 18 * 60 + 30, 0.4, "service override dishwasher") - failed |= check_slot(load_adjust, 20 * 60, 0.0, "service override removed old dishwasher") - failed |= check_slot(load_adjust, 20 * 60 + 30, 0.25, "service override kept heating") - return failed - - -def test_additional_load_sanitized_entity_name_updates_original(my_predbat): - """Test sanitized HA entity names resolve back to the original forecast name.""" - failed = 0 - configure_additional_load_test(my_predbat) - my_predbat.args["house_load_additional_forecast"] = [ - {"name": "Dishwasher Eco", "start_time": "20:00", "duration": 2.0, "energy": 1.2}, - ] - my_predbat.refresh_additional_load_forecast_api() - - service_data = { - "domain": "predbat", - "service": "update_load_forecast_delta", - "service_data": {"entity_id": "binary_sensor.predbat_load_forecast_delta_dishwasher_eco", "start_time": "18:00", "duration": 1.0, "energy": 0.8}, - } - run_async(my_predbat.trigger_callback(service_data)) - failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 18 * 60, 0.4, "sanitized service updated original") - failed |= check_slot(my_predbat.house_load_additional_forecast_adjust, 20 * 60, 0.0, "sanitized service removed original slot") - if "Dishwasher Eco" not in my_predbat.house_load_additional_forecasts: - print("ERROR: Sanitized service should keep original forecast name, got {}".format(my_predbat.house_load_additional_forecasts)) - failed = 1 - + failed |= check_slot(load_adjust, 18 * 60, 0.4, "api override dishwasher") + failed |= check_slot(load_adjust, 18 * 60 + 30, 0.4, "api override dishwasher") + failed |= check_slot(load_adjust, 20 * 60, 0.0, "api override removed old dishwasher") + failed |= check_slot(load_adjust, 20 * 60 + 30, 0.25, "api override kept heating") my_predbat.api_select("load_forecast_delta_api", "off") my_predbat.house_load_additional_forecast_overrides = {} return failed @@ -969,8 +940,7 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_dishwasher_total_energy(my_predbat) failed |= test_additional_load_dishwasher_total_energy_weighting(my_predbat) failed |= test_additional_load_partial_duration_keeps_total_energy(my_predbat) - failed |= test_additional_load_multiple_and_service_override(my_predbat) - failed |= test_additional_load_sanitized_entity_name_updates_original(my_predbat) + failed |= test_additional_load_multiple_and_api_override(my_predbat) failed |= test_additional_load_select_api_override(my_predbat) failed |= test_additional_load_select_api_weighting(my_predbat) failed |= test_additional_load_select_event_updates_adjustment(my_predbat) diff --git a/apps/predbat/userinterface.py b/apps/predbat/userinterface.py index cca5a12ae..2d1212b6f 100644 --- a/apps/predbat/userinterface.py +++ b/apps/predbat/userinterface.py @@ -880,40 +880,8 @@ async def trigger_callback(self, service_data): # self.log("Trigger callback for {} {}".format(item["domain"], item["service"])) await item["callback"](item["service"], service_data, None) - async def load_forecast_delta_event(self, service, data, kwargs): - """ - Update a named additional load forecast from a Home Assistant service call. - """ - service_data = data.get("service_data", {}) if data else {} - target = data.get("target", {}) if data else {} - entity_id = service_data.get("entity_id", None) or target.get("entity_id", None) - if isinstance(entity_id, list): - entity_id = entity_id[0] if entity_id else None - name = service_data.get("name", None) - if not name and entity_id: - name = self.additional_load_name_from_entity(entity_id) - if not name: - self.log("Warn: update_load_forecast_delta called without name or target entity_id") - self.record_status("Warn: update_load_forecast_delta called without name or target entity_id", had_errors=True) - return - name = self.resolve_additional_load_name(name) - - forecast = {"name": str(name), "_source": "service", "_auto_expire": True} - for key in ["start_time", "end_time", "duration", "energy", "slot_energy", "weighting", "enabled", "mode"]: - if key in service_data: - forecast[key] = service_data[key] - if "start_time" not in forecast: - plan_interval = self.get_arg("plan_interval_minutes", 30) - forecast["_requested_start_minutes"] = int(self.minutes_now / plan_interval) * plan_interval - self.house_load_additional_forecast_overrides[str(name)] = forecast - await self.run_in_executor(self.refresh_additional_load_forecast_api) - self.plan_valid = False - self.update_pending = True - self.log("Updated additional load forecast {} via service {}".format(name, service)) - def define_service_list(self): self.SERVICE_REGISTER_LIST = [ - {"domain": "predbat", "service": "update_load_forecast_delta"}, {"domain": "input_number", "service": "set_value"}, {"domain": "input_number", "service": "increment"}, {"domain": "input_number", "service": "decrement"}, @@ -928,7 +896,6 @@ def define_service_list(self): {"domain": "select", "service": "select_previous"}, ] self.EVENT_LISTEN_LIST = [ - {"domain": "predbat", "service": "update_load_forecast_delta", "callback": self.load_forecast_delta_event}, {"domain": "switch", "service": "turn_on", "callback": self.switch_event}, {"domain": "switch", "service": "turn_off", "callback": self.switch_event}, {"domain": "switch", "service": "toggle", "callback": self.switch_event}, From 0530f74740cc887d76db3b2b5279a2f95164424c Mon Sep 17 00:00:00 2001 From: Andreas Scholdan Date: Fri, 29 May 2026 06:15:30 +0200 Subject: [PATCH 25/25] Roll flexible deadlines to next reachable time --- apps/predbat/fetch.py | 4 ++- .../tests/test_additional_load_forecast.py | 36 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index a521016b5..66f6e5fcb 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -471,8 +471,10 @@ def get_additional_load_window(self, load_item, mode, duration, plan_interval, m for window_start, window_end in sorted(windows): usable_start = max(window_start, minutes_now_slot) + if usable_start + duration_minutes > window_end: + window_end += 24 * 60 if usable_start + duration_minutes <= window_end and usable_start < minutes_now_slot + self.forecast_minutes: - return usable_start, min(window_end, minutes_now_slot + self.forecast_minutes) + return usable_start, window_end return None, None if start_minutes is None: diff --git a/apps/predbat/tests/test_additional_load_forecast.py b/apps/predbat/tests/test_additional_load_forecast.py index 079dd371c..100bc08d0 100644 --- a/apps/predbat/tests/test_additional_load_forecast.py +++ b/apps/predbat/tests/test_additional_load_forecast.py @@ -762,6 +762,41 @@ def test_additional_load_flexible_done_by_window(my_predbat): return failed +def test_additional_load_flexible_done_by_next_reachable_deadline(my_predbat): + """Test flexible end_time rolls to the next reachable deadline when today's deadline cannot fit.""" + failed = 0 + configure_additional_load_test(my_predbat) + my_predbat.forecast_minutes = 30 * 60 + my_predbat.minutes_now = 6 * 60 + my_predbat.args["house_load_additional_forecast"] = [ + {"name": "dishwasher", "mode": "flexible", "end_time": "07:00", "duration": 5.0, "energy": 0.7}, + ] + + _, forecasts = my_predbat.fetch_additional_load_forecast() + forecast = forecasts.get("dishwasher", {}) + if "T06:00:00" not in forecast.get("requested_start", "") or "T07:00:00" not in forecast.get("requested_end", ""): + print("ERROR: Flexible done-by window should run from 06:00 to the next reachable 07:00, got {}".format(forecast)) + failed = 1 + if forecast.get("_requested_end_minutes") != 31 * 60: + print("ERROR: Flexible done-by deadline should roll to tomorrow 07:00, got {}".format(forecast)) + failed = 1 + + my_predbat.minutes_now = 30 + _, forecasts = my_predbat.fetch_additional_load_forecast() + forecast = forecasts.get("dishwasher", {}) + if forecast.get("_requested_end_minutes") != 7 * 60: + print("ERROR: Flexible done-by deadline should use today's 07:00 when the load fits, got {}".format(forecast)) + failed = 1 + + my_predbat.minutes_now = 3 * 60 + _, forecasts = my_predbat.fetch_additional_load_forecast() + forecast = forecasts.get("dishwasher", {}) + if forecast.get("_requested_end_minutes") != 31 * 60: + print("ERROR: Flexible done-by deadline should roll when 5h cannot fit by today's 07:00, got {}".format(forecast)) + failed = 1 + return failed + + def test_additional_load_flexible_api_omitted_start_is_frozen(my_predbat): """Test API flexible forecasts without start_time keep their initial requested start.""" failed = 0 @@ -958,6 +993,7 @@ def run_additional_load_forecast_tests(my_predbat): failed |= test_additional_load_flexible_api_repeat_preserves_metadata(my_predbat) failed |= test_additional_load_flexible_pending_until_plan(my_predbat) failed |= test_additional_load_flexible_done_by_window(my_predbat) + failed |= test_additional_load_flexible_done_by_next_reachable_deadline(my_predbat) failed |= test_additional_load_flexible_api_omitted_start_is_frozen(my_predbat) failed |= test_additional_load_flexible_yaml_omitted_start_rolls(my_predbat) failed |= test_additional_load_flexible_prediction_metric_selection(my_predbat)