From 079509cacdd166435d0ae703354cf2e93fa88595 Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 25 Sep 2026 14:01:48 +0100 Subject: [PATCH 1/7] feat(dynamic-load): cancel a car's slots and IOG cheap rate while it is not charging Dynamic load already cancelled a car's future slots when the load showed it was not charging, but it ran after the rates were built, so an Octopus Intelligent dispatch kept its cheap rate: the house battery was still planned around a slot Octopus may bill at the full rate if the car never draws. The car check now runs inside fetch_sensor_data(), after the car slots are built and before the rates. While a car is inside one of its slots and not charging: - every slot ending after now is set to 0 kWh, releasing "Hold for car" and the predicted car load - rate_add_io_slots() skips the car's dispatch minutes from now on - a discount the rate feed delivered for them (io_adjusted) is set back to rate_max_base before replication, except in the fixed 23:30-05:30 window and where another car still being trusted has a dispatch Evidence is car_charging_now when it is a real entity (2 minutes), otherwise the low-load test when the car is inside the CT clamp (10 minutes), otherwise nothing. The cancellation is re-derived each cycle and ends as soon as the car leaves its slots or starts charging again. Cars Predbat plans itself get their slots after the rates, so a second call decides those; they have no dispatch discount to withhold. A poll in the 15 second loop asks for a replan once a cancel or resume is due, rather than waiting up to 5 minutes for the next cycle. It judges "in a slot" on the same floored, skewed minute as the check, so the two cannot disagree and replan every 15 seconds at a slot boundary. dynamic_load() keeps the high-load baseline; the load classification is shared. An alternative to #5110, which gates trust in future dispatches only through the rates and so leaves "Hold for car" firing for a slot it no longer trusts. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/const.py | 7 + apps/predbat/fetch.py | 8 +- apps/predbat/octopus.py | 51 +++ apps/predbat/plan.py | 188 +++++++-- apps/predbat/predbat.py | 6 + apps/predbat/tests/test_dynamic_load.py | 75 +--- apps/predbat/tests/test_dynamic_load_car.py | 414 ++++++++++++++++++++ apps/predbat/unit_test.py | 2 + docs/car-charging.md | 4 +- docs/customisation.md | 6 +- 10 files changed, 665 insertions(+), 96 deletions(-) create mode 100644 apps/predbat/tests/test_dynamic_load_car.py diff --git a/apps/predbat/const.py b/apps/predbat/const.py index a752afd10..8d76cac99 100644 --- a/apps/predbat/const.py +++ b/apps/predbat/const.py @@ -24,6 +24,13 @@ TIME_FORMAT_SOLIS = "%Y-%m-%d %H:%M:%S" PREDICT_STEP = 5 +# Dynamic load: how long a car inside one of its charging slots must show "not charging" before its +# slots, and their cheap rate, are cancelled. car_charging_now reports the car directly, so two +# minutes (one charger/integration update after the slot starts) is enough. The load test reads a +# PREDICT_STEP average that the rest of the house also moves, so it needs two full periods. +DYNAMIC_LOAD_CAR_SENSOR_MINUTES = 2 +DYNAMIC_LOAD_CAR_LOAD_MINUTES = 10 + # Extra cloud divergence applied to the PV10 scenario on top of the computed cloud factor, so the # downside case diverges harder than the central one CLOUD_FACTOR_PV10 = 0.2 diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index b2990ab31..8c0fbfca3 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1184,6 +1184,9 @@ def fetch_sensor_data(self, save=True): # Fetch sensor data for cars, e.g. car plan, car energy, car sessions etc. self.dispatch_timeline_pending = [] self.fetch_sensor_data_cars(save=save) + # Dynamic load: cancel the slots of a car that is in one but not charging - before the rates are + # built, so a cancelled Intelligent dispatch never gets its cheap rate + dynamic_load_car_changed = self.dynamic_load_car_check(save=save) if "rates_export_octopus_url" in self.args: # Fixed URL for rate export @@ -1237,6 +1240,7 @@ def fetch_sensor_data(self, save=True): if import_rates: self.rate_scan(import_rates, print=False) self.rate_import_base, self.rate_min_base, self.rate_max_base = self.rate_base_min_max(import_rates) + import_rates = self.dynamic_load_car_strip_feed_rates(import_rates) import_rates, self.rate_import_replicated = self.rate_replicate(import_rates, self.io_adjusted, is_import=True) self.rate_import_no_io = import_rates.copy() for car_n in range(self.num_cars): @@ -1310,6 +1314,8 @@ def fetch_sensor_data(self, save=True): # Work out car plan? self.fetch_sensor_data_car_planning() + # Dynamic load for the cars Predbat plans itself, whose slots only exist now + dynamic_load_car_changed |= self.dynamic_load_car_check(save=save, late=True) # Publish the car plan self.publish_car_plan() @@ -1367,7 +1373,7 @@ def fetch_sensor_data(self, save=True): else: self.load_inday_adjustment = 1.0 - force_replan = False + force_replan = dynamic_load_car_changed # Compare on the change-detection signature, not the raw slots, so the per-cycle re-clocking # of an in-progress dispatch (start advanced to now, energy scaled to remaining time) does not # force a replan every cycle while a slot is active - only genuine slot changes do diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index fa0fd31d5..b74f85cd2 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3601,6 +3601,10 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): slots_added_set = set() plan_interval_minutes = self.plan_interval_minutes saved_slots = set() # For logging purposes, track which slots we actually applied as low rate + # Dynamic load has seen this car in its slot but not charging: none of its dispatches from now + # on get the cheap rate (see dynamic_load_car_check()). Elapsed minutes keep theirs - they record + # what the tariff charged, and today's cost is built from them. + car_cancelled = self.dynamic_load_car_cancelled.get(car_n, False) if octopus_slots: # Add in IO slots @@ -3638,6 +3642,9 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # is behaviour-preserving there and simply avoids the staleness this PR adds.) assumed_price = rates.get(start_minutes, self.rate_min) + if car_cancelled and minute >= self.minutes_now: + continue # Car is not charging, its dispatch is not trusted as cheap + if minute in saved_slots: continue # Already applied a low rate slot to this minute, skip else: @@ -3688,6 +3695,50 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): return rates + def dynamic_load_car_strip_feed_rates(self, rates): + """ + Remove an Octopus Intelligent discount the rate feed itself delivered for a dispatch of a car that + dynamic load has cancelled (see dynamic_load_car_check()). + + The feed marks dispatch minutes as is_intelligent_adjusted (self.io_adjusted) and has already + lowered their price, so unlike rate_add_io_slots()'s own overlay there is nothing to withhold - + the price has to be overwritten. Called before the rates are replicated and snapshotted, so + nothing downstream sees the discount. + + A minute is set back to rate_max_base, and loses its io_adjusted marker, only when it is from now + onwards, outside the fixed 23:30-05:30 window (cheap by tariff, not by dispatch), covered by a + cancelled car's dispatch and not covered by a dispatch of any car still trusted - the rates are + shared by every car. + """ + cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_cancelled.get(car_n, False)] + if not cancelled_cars or not self.io_adjusted: + return rates + + cancelled_minutes = set() + trusted_minutes = set() + for car_n in range(min(self.num_cars, len(self.octopus_slots))): + covered = cancelled_minutes if car_n in cancelled_cars else trusted_minutes + for slot in self.octopus_slots[car_n]: + start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True) + covered.update(range(max(start_minutes, self.minutes_now), end_minutes)) + + window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"] + window_start = window["start"][0] * 60 + window["start"][1] + window_end = window["end"][0] * 60 + window["end"][1] + stripped = 0 + for minute in sorted(cancelled_minutes - trusted_minutes): + if not self.io_adjusted.get(minute, False): + continue + minute_of_day = minute % (24 * 60) + if minute_of_day >= window_start or minute_of_day < window_end: + continue + rates[minute] = self.rate_max_base + del self.io_adjusted[minute] + stripped += 1 + if stripped: + self.log("Dynamic load: removed the Intelligent dispatch rate from {} minutes of cars {} which are not charging".format(stripped, cancelled_cars)) + return rates + def fetch_octopus_rates(self, entity_id, adjust_key=None): """ Fetch the Octopus rates from the sensor diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 98583f88f..a21bbffe6 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -23,6 +23,8 @@ from const import ( CLOUD_FACTOR_PV10, CLOUD_WINDOW_MINUTES, + DYNAMIC_LOAD_CAR_LOAD_MINUTES, + DYNAMIC_LOAD_CAR_SENSOR_MINUTES, PREDICT_STEP, PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, @@ -53,6 +55,8 @@ pack_export_limit, export_limit_exports_no_battery, export_limit_is_full_discharge, + is_entity_id, + minutes_since_midnight, ) from prediction import Prediction from prediction_kernel import kernel_status_summary, set_window_start @@ -226,20 +230,12 @@ def dynamic_load(self): Return True if load status has changed and hence we need to re-plan """ prev_last_load_status = self.load_last_status - prev_last_load_car_slot = self.load_last_car_slot threshold_battery = self.battery_rate_max_discharge * MINUTE_WATT / 1000 threshold_car = self.car_charging_threshold * MINUTE_WATT / 1000 # Last period load analysis - self.load_last_status = "baseline" - if self.load_last_period >= threshold_battery: - self.load_last_status = "high" - elif (self.load_last_period < (threshold_battery * 0.9)) and (self.load_last_period < (threshold_car * 0.9)): - # Check if the load is less than car charging threshold - self.load_last_status = "low" - else: - self.load_last_status = "baseline" + self.load_last_status = self.dynamic_load_classify() # Update entity for last load self.dashboard_item( @@ -295,21 +291,10 @@ def dynamic_load(self): if self.metric_dynamic_load_adjust: minutes_now = self.minutes_now minutes_end_slot = int((self.minutes_now + self.plan_interval_minutes) / self.plan_interval_minutes) * self.plan_interval_minutes - # When dynamic load is enabled we try can do two things - # 1. Increase the load prediction in the current self.plan_interval_minutes minute period to match the actual load (if the load is higher than expected), - # extending into the following period too once the load has been high for two consecutive checks in a row (mirrors the low-load debounce below) - # 2. If the load is low and car charging is predicted then cancel off future car slots - # Note never do this just after midnight due to the load sensor reset - if self.load_last_status == "low" and self.minutes_now > 5: - if load_car_slot and prev_last_load_car_slot: - for car_n in range(0, self.num_cars): - for slot_n in range(0, len(self.car_charging_slots[car_n])): - slot = self.car_charging_slots[car_n][slot_n] - if slot["end"] > minutes_now: - # If the slot is in the future - self.log("Dynamic load adjust is cancelling car {} slot {}-{} due to low load".format(car_n, slot["start"], slot["end"])) - self.car_charging_slots[car_n][slot_n]["kwh"] = 0 - + # When dynamic load is enabled, increase the load prediction in the current self.plan_interval_minutes minute period to match the + # actual load (if the load is higher than expected), extending into the following period too once the load has been high for two + # consecutive checks in a row. Cancelling the slots of a car that is not charging is done earlier in the cycle, before the rates + # are built, by dynamic_load_car_check(). if self.load_last_status == "high": have_printed = False minutes_end_baseline = minutes_end_slot @@ -330,6 +315,161 @@ def dynamic_load(self): return False + def dynamic_load_classify(self): + """ + Classify load_last_period as "high" (above the battery's discharge rate), "low" (below both the + battery rate and the car charging threshold, so no car can be charging) or "baseline". + + Shared by dynamic_load(), which runs after the inverter fetch, and dynamic_load_car_check(), which + runs before it and so uses the previous cycle's battery_rate_max_discharge - it barely moves + between cycles, and the car grace period spans several of them. + """ + threshold_battery = self.battery_rate_max_discharge * MINUTE_WATT / 1000 + threshold_car = self.car_charging_threshold * MINUTE_WATT / 1000 + if self.load_last_period >= threshold_battery: + return "high" + if (self.load_last_period < (threshold_battery * 0.9)) and (self.load_last_period < (threshold_car * 0.9)): + return "low" + return "baseline" + + def dynamic_load_car_evidence(self, car_n): + """ + Whether car_n is not charging right now, and how long that must hold before its slots are cancelled. + + Returns (not_charging, grace_minutes) where not_charging is True (not charging), False (charging) + or None (no evidence either way). + + car_charging_now is used whenever it names a real entity - a static literal in apps.yaml can never + report the car stopping. An "unknown"/"unavailable" reading is no evidence, rather than a stop. + Without a sensor the low-load test stands in, but only when the car is inside the CT clamp + (car_energy_reported_load): otherwise its charging never shows in the load at all. The load test + is skipped just after midnight, when the load_today sensor resets. + """ + if is_entity_id(self.get_arg("car_charging_now", None, indirect=False, index=car_n)): + raw = self.get_arg("car_charging_now", "no", index=car_n) + if raw is None or (isinstance(raw, str) and raw.lower() in ("unknown", "unavailable")): + return None, DYNAMIC_LOAD_CAR_SENSOR_MINUTES + if isinstance(raw, str): + charging = raw.lower() in self.car_charging_now_response + else: + charging = bool(raw) + return (not charging), DYNAMIC_LOAD_CAR_SENSOR_MINUTES + if self.car_energy_reported_load: + if self.minutes_now <= 5: + return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES + return self.dynamic_load_classify() == "low", DYNAMIC_LOAD_CAR_LOAD_MINUTES + return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES + + def dynamic_load_car_in_slot(self, car_n, minute): + """ + Whether minute falls inside any of car_n's charging slots, whatever their kWh - a slot the + check has already cancelled is still one the car is meant to be charging in. + """ + for slot in self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else []: + if slot["start"] <= minute < slot["end"]: + return True + return False + + def dynamic_load_car_check(self, save=True, late=False): + """ + Cancel the slots of a car that is inside one of its charging slots but not charging. + + Runs in fetch_sensor_data() before the rates are built, so that a cancelled Octopus Intelligent + dispatch never gets its cheap rate (rate_add_io_slots() and dynamic_load_car_strip_feed_rates() + consult dynamic_load_car_cancelled). Cars that Predbat plans itself only get their slots after the + rates, from the low-rate scan, so a second call with late=True decides those once they exist - + they have no dispatch discount to withhold. + + A car that has shown "not charging" for its grace period (see dynamic_load_car_evidence()) has + every slot ending after now set to 0 kWh, which releases "Hold for car" and the predicted car + load. That is re-derived every cycle from freshly built slots, so the cancellation ends as soon + as the car leaves its slots or starts charging again. + + With save False (compare.py re-running the fetch for another tariff) the saved decision is + applied but not advanced. + + Returns True when a car's cancellation changed, so the plan is recomputed. + """ + changed = False + if not late: + self.dynamic_load_car_decided = set() + + for car_n in range(self.num_cars): + if car_n in self.dynamic_load_car_decided: + continue + if not late and not (car_n < len(self.car_charging_slots) and self.car_charging_slots[car_n]): + continue + self.dynamic_load_car_decided.add(car_n) + + was_cancelled = self.dynamic_load_car_cancelled.get(car_n, False) + if save: + cancelled = was_cancelled + if not self.metric_dynamic_load_adjust or not self.dynamic_load_car_in_slot(car_n, self.minutes_now): + self.dynamic_load_car_since.pop(car_n, None) + cancelled = False + else: + not_charging, grace_minutes = self.dynamic_load_car_evidence(car_n) + if not_charging is False: + self.dynamic_load_car_since.pop(car_n, None) + cancelled = False + elif not_charging: + since = self.dynamic_load_car_since.setdefault(car_n, self.now_utc_real) + if (self.now_utc_real - since).total_seconds() >= grace_minutes * 60: + cancelled = True + if cancelled != was_cancelled: + changed = True + self.log("Dynamic load: car {} {}".format(car_n, "is in a charging slot but not charging, cancelling its slots" if cancelled else "slots resumed")) + self.dynamic_load_car_cancelled[car_n] = cancelled + else: + cancelled = was_cancelled and self.metric_dynamic_load_adjust + + if cancelled: + for slot in self.car_charging_slots[car_n]: + if slot["end"] > self.minutes_now: + slot["kwh"] = 0 + return changed + + def dynamic_load_car_poll(self, now=None): + """ + Called from the 15 second loop between plan cycles: ask for a replan as soon as a car's + cancellation is due to change, rather than waiting up to 5 minutes for the next cycle. + + Only car_charging_now is polled - the load test needs the fetch. The clock is started here too, + so a slot that begins between cycles is timed from when the car was first seen idle in it. + The real decision is still made by dynamic_load_car_check() in the replan this triggers. + + Returns True when it set update_pending. + """ + if not self.metric_dynamic_load_adjust or not self.num_cars or self.midnight_utc is None: + return False + if now is None: + now = datetime.now(self.local_tz) + # Judge "in a slot" on the minute the replan's check will use - clock skew added and floored to + # PREDICT_STEP, as update_time() builds minutes_now - or for a slot ending between steps the poll + # would keep asking for a reset the replan then declines, every 15 seconds. The grace is still + # timed on the exact clock, as dynamic_load_car_check() times it on now_utc_real. + minute = minutes_since_midnight(now + timedelta(minutes=self.args.get("clock_skew", 0)), self.midnight_utc) + + due = False + for car_n in range(self.num_cars): + if not is_entity_id(self.get_arg("car_charging_now", None, indirect=False, index=car_n)): + continue + cancelled = self.dynamic_load_car_cancelled.get(car_n, False) + if not self.dynamic_load_car_in_slot(car_n, minute): + due |= cancelled + continue + not_charging, grace_minutes = self.dynamic_load_car_evidence(car_n) + if not_charging is False: + due |= cancelled + elif not_charging and not cancelled: + since = self.dynamic_load_car_since.setdefault(car_n, now) + due |= (now - since).total_seconds() >= grace_minutes * 60 + + if due: + self.log("Dynamic load: car charging state changed, will re-plan") + self.update_pending = True + return due + def find_price_levels( self, price_set, diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 4ba550789..2de2e8e7d 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -545,6 +545,9 @@ def reset(self): self.load_last_period = 0 self.load_last_status = "baseline" self.load_last_car_slot = False + self.dynamic_load_car_since = {} + self.dynamic_load_car_cancelled = {} + self.dynamic_load_car_decided = set() self.battery_capacity_nominal = False self.battery_scaling_auto = False self.releases = {} @@ -2133,6 +2136,9 @@ def update_time_loop(self, cb_args): self.check_entity_refresh() self.validate_config_check_retry() + if not self.prediction_started: + # A car's charging slots cancelled or resumed by dynamic load - replan now, not in 5 minutes + self.dynamic_load_car_poll() if self.update_pending and not self.prediction_started: # Full update required self.update_pending = False diff --git a/apps/predbat/tests/test_dynamic_load.py b/apps/predbat/tests/test_dynamic_load.py index 035d96c70..3ee59d75a 100644 --- a/apps/predbat/tests/test_dynamic_load.py +++ b/apps/predbat/tests/test_dynamic_load.py @@ -61,8 +61,10 @@ def test_dynamic_load_car_slot_cancellation(my_predbat): print(f"ERROR: Car slot 1 kwh should not have changed, was {original_slot_1_kwh}, now {my_predbat.car_charging_slots[1][0]['kwh']}") failed = True - # Test 2: Low load - should cancel car slots that overlap with current time - print("Test 2: Low load case") + # Test 2: Low load - dynamic_load() classifies it but no longer cancels car slots itself; that + # moved earlier in the cycle, before the rates are built (dynamic_load_car_check(), see + # test_dynamic_load_car.py), so it can withhold an Intelligent dispatch's cheap rate too + print("Test 2: Low load case leaves the car slots to dynamic_load_car_check()") my_predbat.load_last_status = "baseline" # Reset status my_predbat.load_last_period = 2.0 # 2kW - low load (< battery_rate_max_discharge * 0.9 * MINUTE_WATT / 1000 and < car_charging_threshold * 0.9) my_predbat.load_last_car_slot = True @@ -78,73 +80,8 @@ def test_dynamic_load_car_slot_cancellation(my_predbat): print(f"ERROR: Expected load_last_status to be 'low', got '{my_predbat.load_last_status}'") failed = True - # Verify that car slot 0 was cancelled (overlaps with current time) - if my_predbat.car_charging_slots[0][0]["kwh"] != 0: - print(f"ERROR: Car slot 0 should have been cancelled (kwh=0), but kwh = {my_predbat.car_charging_slots[0][0]['kwh']}") - failed = True - - # Verify that car slot 1 was also cancelled (overlaps with current 30-minute period) - if my_predbat.car_charging_slots[1][0]["kwh"] != 0: - print(f"ERROR: Car slot 1 should have been cancelled (kwh=0), but kwh = {my_predbat.car_charging_slots[1][0]['kwh']}") - failed = True - - # Test 3: Low load but slots don't overlap with current time - should not cancel - print("Test 3.1: Low load with non-overlapping slots, first time car starts") - my_predbat.load_last_status = "baseline" # Reset status - my_predbat.load_last_period = 2.0 # Low load - my_predbat.load_last_car_slot = False - - # Create slots that don't overlap with current time period - my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now - 5, "end": my_predbat.minutes_now + 90, "kwh": 10.0}] - my_predbat.car_charging_slots[1] = [{"start": my_predbat.minutes_now - 60, "end": my_predbat.minutes_now - 30, "kwh": 8.0}] - - # Call dynamic_load - my_predbat.dynamic_load() - - if my_predbat.car_charging_slots[0][0]["kwh"] != 10.0: - print(f"ERROR: Car slot 0 should not have been cancelled, kwh = {my_predbat.car_charging_slots[0][0]['kwh']}") - failed = True - - if my_predbat.car_charging_slots[1][0]["kwh"] != 8.0: - print(f"ERROR: Car slot 1 should not have been cancelled, kwh = {my_predbat.car_charging_slots[1][0]['kwh']}") - failed = True - - # Test 3: Low load but slots don't overlap with current time - should not cancel - print("Test 3.2: Low load with non-overlapping slots") - my_predbat.load_last_status = "baseline" # Reset status - my_predbat.load_last_period = 2.0 # Low load - - # Create slots that don't overlap with current time period - my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now - 5, "end": my_predbat.minutes_now + 90, "kwh": 10.0}] - my_predbat.car_charging_slots[1] = [{"start": my_predbat.minutes_now - 60, "end": my_predbat.minutes_now - 30, "kwh": 8.0}] - - # Call dynamic_load - my_predbat.dynamic_load() - - if my_predbat.car_charging_slots[0][0]["kwh"] != 0: - print(f"ERROR: Car slot 0 should have been cancelled, kwh = {my_predbat.car_charging_slots[0][0]['kwh']}") - failed = True - - if my_predbat.car_charging_slots[1][0]["kwh"] != 8.0: - print(f"ERROR: Car slot 1 should not have been cancelled, kwh = {my_predbat.car_charging_slots[1][0]['kwh']}") - failed = True - - # Test 4: Just after midnight (minutes_now <= 5) - should not cancel even with low load - print("Test 4: Low load just after midnight") - my_predbat.minutes_now = 3 # 3 minutes after midnight - my_predbat.load_last_status = "baseline" # Reset status - my_predbat.load_last_period = 2.0 # Low load - my_predbat.load_last_car_slot = False - - # Create slots that overlap with current time - my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 25, "kwh": 10.0}] - - # Call dynamic_load - my_predbat.dynamic_load() - - # Verify slots were NOT cancelled (due to midnight exclusion) - if my_predbat.car_charging_slots[0][0]["kwh"] != 10.0: - print(f"ERROR: Car slot should not have been cancelled just after midnight, kwh = {my_predbat.car_charging_slots[0][0]['kwh']}") + if my_predbat.car_charging_slots[0][0]["kwh"] != 10.0 or my_predbat.car_charging_slots[1][0]["kwh"] != 8.0: + print(f"ERROR: dynamic_load() should leave car slots alone, got kwh {my_predbat.car_charging_slots[0][0]['kwh']} and {my_predbat.car_charging_slots[1][0]['kwh']}") failed = True # Test 5: Metric dynamic load adjust disabled - should not cancel slots diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py new file mode 100644 index 000000000..6ae84079d --- /dev/null +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -0,0 +1,414 @@ +# ----------------------------------------------------------------------------- +# 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 +""" +Dynamic load car-not-charging detection. + +With metric_dynamic_load_adjust on, a car inside one of its charging slots that is not actually +charging has every future slot cancelled (kwh 0, which releases "Hold for car" and the predicted car +load) and, for an Octopus Intelligent dispatch, loses the dispatch's cheap rate too - so the house +battery is not planned around a slot Octopus may bill at the full rate. The cancellation is +re-derived every cycle and ends as soon as the car leaves its slots or starts charging again. + +Evidence is car_charging_now when it is a real entity (2 minute grace), otherwise the low-load test +when the car is inside the CT clamp (10 minute grace), otherwise nothing. +""" +import copy +from datetime import timedelta + +TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" +SENSOR = "binary_sensor.car_charging_now_test" + +STATE_FIELDS = ( + "num_cars", + "minutes_now", + "now_utc_real", + "car_charging_slots", + "car_energy_reported_load", + "car_charging_threshold", + "battery_rate_max_discharge", + "load_last_period", + "metric_dynamic_load_adjust", + "octopus_slots", + "io_adjusted", + "rate_max_base", + "rate_min_base", + "forecast_minutes", + "update_pending", + "dynamic_load_car_since", + "dynamic_load_car_cancelled", + "dynamic_load_car_decided", +) + + +def _slots(): + """ + Fresh car slots as the fetch would rebuild them each cycle: one in progress from 14:00 and a + later one from 15:00. + """ + return [{"start": 840, "end": 870, "kwh": 3.5, "octopus": True}, {"start": 900, "end": 930, "kwh": 3.5, "octopus": True}] + + +def _at(my_predbat, minute, seconds=0): + """ + Move the clock to minute past midnight (plus seconds), as update_time() would: minutes_now is + floored to the 5 minute PREDICT_STEP while now_utc_real stays exact. + """ + my_predbat.now_utc_real = my_predbat.midnight_utc + timedelta(minutes=minute, seconds=seconds) + my_predbat.minutes_now = int(minute / 5) * 5 + return my_predbat.now_utc_real + + +def _cycle(my_predbat, minute, seconds=0, slots=None, save=True): + """ + One fetch cycle for car 0: rebuild its slots, then run the early (pre-rates) check. + """ + _at(my_predbat, minute, seconds) + my_predbat.car_charging_slots = [copy.deepcopy(slots if slots is not None else _slots())] + [[] for _ in range(my_predbat.num_cars - 1)] + return my_predbat.dynamic_load_car_check(save=save) + + +def _sensor(my_predbat, state): + """ + Point car_charging_now at a real entity reporting state, or remove the entity when state is None. + """ + if state is None: + my_predbat.args.pop("car_charging_now", None) + return + my_predbat.args["car_charging_now"] = SENSOR + my_predbat.ha_interface.dummy_items[SENSOR] = state + + +def _reset(my_predbat): + """ + A single car with dynamic load on and no detection state carried over. + """ + my_predbat.num_cars = 1 + my_predbat.metric_dynamic_load_adjust = True + my_predbat.car_energy_reported_load = False + my_predbat.car_charging_threshold = 3.0 / 60.0 + my_predbat.battery_rate_max_discharge = 5.0 / 60.0 + my_predbat.load_last_period = 3.0 + my_predbat.dynamic_load_car_since = {} + my_predbat.dynamic_load_car_cancelled = {} + my_predbat.dynamic_load_car_decided = set() + my_predbat.update_pending = False + + +def _kwh(my_predbat): + """ + kWh of car 0's slots after the check. + """ + return [slot["kwh"] for slot in my_predbat.car_charging_slots[0]] + + +def _check(name, condition, detail=""): + """ + Print an error for a failed condition and return whether it failed. + """ + if not condition: + print("ERROR: {} {}".format(name, detail)) + return True + return False + + +def _run(my_predbat): + """ + The individual scenarios. + """ + failed = False + saved_sensor = my_predbat.args.get("car_charging_now", None) + had_sensor = "car_charging_now" in my_predbat.args + + try: + # Sensor: two minutes of "not charging" inside a slot cancels this and every later slot + print("Test 1: car_charging_now off for 2 minutes cancels the slots") + _reset(my_predbat) + _sensor(my_predbat, "off") + changed = _cycle(my_predbat, 840, 10) + failed |= _check("t1 first sighting", not changed and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 841, 30) + failed |= _check("t1 inside grace", not changed and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 842, 10) + failed |= _check("t1 after grace", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 845) + failed |= _check("t1 stays cancelled without a second replan", (not changed) and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + # Charging again resets straight away + print("Test 2: car_charging_now on again resumes the slots") + _sensor(my_predbat, "on") + changed = _cycle(my_predbat, 847) + failed |= _check("t2 resume", changed and _kwh(my_predbat) == [3.5, 3.5] and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + # Leaving the slots resets, even though the car is still not charging + print("Test 3: leaving the car slots resets the cancellation") + _sensor(my_predbat, "off") + _cycle(my_predbat, 850) + _cycle(my_predbat, 855) + failed |= _check("t3 cancelled first", _kwh(my_predbat) == [0, 0], "kwh {}".format(_kwh(my_predbat))) + changed = _cycle(my_predbat, 875) + failed |= _check("t3 reset outside slot", changed and _kwh(my_predbat) == [3.5, 3.5] and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + failed |= _check("t3 since cleared", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + + # Unknown readings neither start the clock nor clear a cancellation + print("Test 4: unavailable car_charging_now is no evidence either way") + _reset(my_predbat) + _sensor(my_predbat, "unavailable") + _cycle(my_predbat, 840) + _cycle(my_predbat, 850) + failed |= _check("t4 no cancel on unknown", _kwh(my_predbat) == [3.5, 3.5] and my_predbat.dynamic_load_car_since.get(0) is None, "kwh {}".format(_kwh(my_predbat))) + _sensor(my_predbat, "off") + _cycle(my_predbat, 850) + _cycle(my_predbat, 855) + _sensor(my_predbat, "unavailable") + changed = _cycle(my_predbat, 860) + failed |= _check("t4 unknown keeps cancellation", (not changed) and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + # A static literal is not a sensor, and without the CT clamp there is no load evidence either + print("Test 5: a literal car_charging_now is not evidence") + _reset(my_predbat) + my_predbat.args["car_charging_now"] = "off" + my_predbat.load_last_period = 0.2 + _cycle(my_predbat, 840) + _cycle(my_predbat, 860) + failed |= _check("t5 literal ignored", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + # Load method: only when the car is inside the CT clamp, and only after 10 minutes + print("Test 6: low load inside the CT clamp cancels after 10 minutes") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + _cycle(my_predbat, 840) + changed = _cycle(my_predbat, 845) + failed |= _check("t6 not at 5 minutes", (not changed) and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 850) + failed |= _check("t6 cancels at 10 minutes", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + my_predbat.load_last_period = 4.0 + changed = _cycle(my_predbat, 855) + failed |= _check("t6 load back up resumes", changed and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 6b: the load test is not used just after midnight, when load_today resets") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + early_slots = [{"start": 0, "end": 30, "kwh": 3.5}] + _cycle(my_predbat, 0, slots=early_slots) + _cycle(my_predbat, 5, slots=early_slots) + failed |= _check("t6b no clock before 00:05", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + + print("Test 7: low load with the car outside the CT clamp is not evidence") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = False + my_predbat.load_last_period = 0.5 + _cycle(my_predbat, 840) + _cycle(my_predbat, 860) + failed |= _check("t7 no load evidence outside CT", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + print("Test 8: a wired sensor wins over the load test") + _reset(my_predbat) + _sensor(my_predbat, "on") + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + _cycle(my_predbat, 840) + _cycle(my_predbat, 860) + failed |= _check("t8 sensor says charging", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + print("Test 9: dynamic load off does nothing and clears state") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + _cycle(my_predbat, 845) + my_predbat.metric_dynamic_load_adjust = False + changed = _cycle(my_predbat, 850) + failed |= _check("t9 off", changed and _kwh(my_predbat) == [3.5, 3.5] and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 10: compare.py runs (save=False) apply the cancellation but do not advance it") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840, save=False) + failed |= _check("t10 no clock started", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + _cycle(my_predbat, 840) + _cycle(my_predbat, 845) + _cycle(my_predbat, 850, save=False) + failed |= _check("t10 applied when saved state is cancelled", _kwh(my_predbat) == [0, 0], "kwh {}".format(_kwh(my_predbat))) + + # Cars planned by Predbat itself get their slots after the rates are built: the late call + # decides the cars the early call could not + print("Test 11: the late call decides cars that had no slots at the early call") + _reset(my_predbat) + _sensor(my_predbat, "off") + _at(my_predbat, 840) + my_predbat.car_charging_slots = [[]] + my_predbat.dynamic_load_car_check() + my_predbat.car_charging_slots = [_slots()] + my_predbat.dynamic_load_car_check(late=True) + _at(my_predbat, 845) + my_predbat.car_charging_slots = [[]] + my_predbat.dynamic_load_car_check() + my_predbat.car_charging_slots = [_slots()] + changed = my_predbat.dynamic_load_car_check(late=True) + failed |= _check("t11 late cancel", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + failed |= _run_rates(my_predbat) + failed |= _run_poll(my_predbat) + finally: + if had_sensor: + my_predbat.args["car_charging_now"] = saved_sensor + else: + my_predbat.args.pop("car_charging_now", None) + my_predbat.ha_interface.dummy_items.pop(SENSOR, None) + + return failed + + +def _dispatch(my_predbat, start_minute, end_minute): + """ + A raw Octopus dispatch entry covering start_minute to end_minute past midnight. + """ + start = my_predbat.midnight_utc + timedelta(minutes=start_minute) + end = my_predbat.midnight_utc + timedelta(minutes=end_minute) + return {"start": start.strftime(TIME_FORMAT), "end": end.strftime(TIME_FORMAT), "charge_in_kwh": 2.0, "source": "smart-charge", "location": "AT_HOME"} + + +def _run_rates(my_predbat): + """ + The cheap rate of a cancelled car's dispatch is never applied, and a discount the rate feed + delivered for it is stripped before the rates are replicated. + """ + failed = False + saved_low_rate = my_predbat.args.get("octopus_slot_low_rate", None) + saved_slot_max = my_predbat.args.get("octopus_slot_max", None) + try: + print("Test 12: rate_add_io_slots skips the future minutes of a cancelled car") + _reset(my_predbat) + _at(my_predbat, 840) + my_predbat.forecast_minutes = 2 * 24 * 60 + my_predbat.args["octopus_slot_low_rate"] = True + my_predbat.args["octopus_slot_max"] = 48 + my_predbat.rate_min_base = 4.0 + my_predbat.rate_max_base = 30.0 + my_predbat.dynamic_load_car_cancelled = {0: True} + rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} + rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 810, 900)]) + failed |= _check("t12 past minute keeps its discount", rates[820] == 4.0, "rate {}".format(rates[820])) + failed |= _check("t12 future minute not discounted", rates[840] == 10.0 and rates[899] == 10.0, "rates {} {}".format(rates[840], rates[899])) + + my_predbat.dynamic_load_car_cancelled = {} + rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} + rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 810, 900)]) + failed |= _check("t12 not cancelled is discounted", rates[840] == 4.0, "rate {}".format(rates[840])) + + print("Test 13: a feed-side discount for a cancelled car's dispatch is stripped") + _reset(my_predbat) + _at(my_predbat, 840) + my_predbat.num_cars = 2 + my_predbat.rate_max_base = 30.0 + my_predbat.dynamic_load_car_cancelled = {0: True} + my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]] + rates = {minute: 25.0 for minute in range(0, 2 * 24 * 60)} + my_predbat.io_adjusted = {} + for minute in list(range(810, 930)) + list(range(1410, 1440)): + rates[minute] = 7.0 + my_predbat.io_adjusted[minute] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + failed |= _check("t13 future stripped", rates[850] == 30.0 and 850 not in my_predbat.io_adjusted, "rate {} marker {}".format(rates[850], my_predbat.io_adjusted.get(850))) + failed |= _check("t13 past kept", rates[820] == 7.0 and my_predbat.io_adjusted.get(820), "rate {}".format(rates[820])) + failed |= _check("t13 other car's dispatch kept", rates[910] == 7.0 and my_predbat.io_adjusted.get(910), "rate {}".format(rates[910])) + failed |= _check("t13 fixed window kept", rates[1420] == 7.0 and my_predbat.io_adjusted.get(1420), "rate {}".format(rates[1420])) + + my_predbat.dynamic_load_car_cancelled = {} + rates[850] = 7.0 + my_predbat.io_adjusted[850] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + failed |= _check("t13 nothing cancelled is a no-op", rates[850] == 7.0 and my_predbat.io_adjusted.get(850), "rate {}".format(rates[850])) + finally: + for key, value in (("octopus_slot_low_rate", saved_low_rate), ("octopus_slot_max", saved_slot_max)): + if value is None: + my_predbat.args.pop(key, None) + else: + my_predbat.args[key] = value + return failed + + +def _run_poll(my_predbat): + """ + The 15 second loop asks for a replan as soon as a cancel or resume is due, rather than waiting + for the next 5 minute cycle. + """ + failed = False + print("Test 14: the poll asks for a replan once the grace has run out") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840, 5) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=841)) + failed |= _check("t14 not yet due", (not due) and not my_predbat.update_pending, "due {}".format(due)) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=842, seconds=10)) + failed |= _check("t14 due after grace", due and my_predbat.update_pending, "due {}".format(due)) + + print("Test 15: the poll starts the clock itself when the slot began between cycles") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 835) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=840, seconds=20)) + failed |= _check("t15 clock started", (not due) and my_predbat.dynamic_load_car_since.get(0) is not None, "due {} since {}".format(due, my_predbat.dynamic_load_car_since.get(0))) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=842, seconds=30)) + failed |= _check("t15 due", due, "due {}".format(due)) + + print("Test 16: the poll asks for a replan when a cancelled car starts charging") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + _cycle(my_predbat, 845) + my_predbat.update_pending = False + _sensor(my_predbat, "on") + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=846)) + failed |= _check("t16 resume due", due and my_predbat.update_pending, "due {}".format(due)) + + # The poll must judge "in a slot" exactly as the replan's check will - on minutes_now, floored to + # PREDICT_STEP - or it keeps asking for a change the replan then declines, every 15 seconds + print("Test 18: the poll agrees with the check about a slot ending between 5 minute steps") + _reset(my_predbat) + _sensor(my_predbat, "off") + odd_slots = [{"start": 840, "end": 872, "kwh": 3.5}] + _cycle(my_predbat, 840, slots=odd_slots) + _cycle(my_predbat, 845, slots=odd_slots) + my_predbat.update_pending = False + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=872, seconds=10)) + failed |= _check("t18 no reset before minutes_now leaves the slot", not due, "due {}".format(due)) + changed = _cycle(my_predbat, 872, 25, slots=odd_slots) + failed |= _check("t18 check agrees", (not changed) and my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {}".format(changed)) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=875, seconds=5)) + failed |= _check("t18 reset once minutes_now leaves it", due, "due {}".format(due)) + + print("Test 17: the poll does nothing with dynamic load off") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + my_predbat.metric_dynamic_load_adjust = False + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=845)) + failed |= _check("t17 off", not due, "due {}".format(due)) + return failed + + +def test_dynamic_load_car_not_charging(my_predbat): + """ + Dynamic load cancels the car slots, and their cheap rate, while a car in its slot is not charging. + """ + print("*** Running test: Dynamic load car not charging") + saved_state = {field: copy.deepcopy(getattr(my_predbat, field, None)) for field in STATE_FIELDS} + try: + failed = _run(my_predbat) + finally: + for field, value in saved_state.items(): + setattr(my_predbat, field, value) + print("*** Dynamic load car not charging test {}".format("FAILED" if failed else "PASSED")) + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index d57918509..04b31bb26 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -201,6 +201,7 @@ from tests.test_add_now_to_octopus_slot import test_add_now_to_octopus_slot from tests.test_octopus_slots_change import test_octopus_slots_change from tests.test_dynamic_load import test_dynamic_load_car_slot_cancellation, test_dynamic_load_high_load_baseline +from tests.test_dynamic_load_car import test_dynamic_load_car_not_charging from tests.test_fox_api import run_fox_api_tests from tests.test_deye_const import run_deye_const_tests from tests.test_deye_config import run_deye_config_tests @@ -514,6 +515,7 @@ def main(): ("record_status_state_clamped", test_record_status_state_clamped, "Status sensor state is clamped at the 255 characters Home Assistant accepts", False), ("dynamic_load_car", test_dynamic_load_car_slot_cancellation, "Dynamic load car slot cancellation tests", False), ("dynamic_load_high", test_dynamic_load_high_load_baseline, "Dynamic load high-load baseline tests", False), + ("dynamic_load_car_not_charging", test_dynamic_load_car_not_charging, "Dynamic load car not charging tests", False), ("units", run_test_units, "Unit tests", False), ("manual_api", run_test_manual_api, "Manual API tests", False), ("manual_soc", run_test_manual_soc, "Manual SOC target tests", False), diff --git a/docs/car-charging.md b/docs/car-charging.md index 8a45f1ad4..a66ad2379 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -615,7 +615,9 @@ e.g. you are using Intelligent Octopus or you use the car slots in Predbat to co - **input_number.predbat_car_charging_loss** gives the percentage amount of energy lost when charging the car (load in the home vs energy added to the battery). A good setting is 0.08 which is 8%. -- **switch.predbat_metric_dynamic_load_adjust** (default `false`) - If turned Off then Predbat won't export during times the car is planned to charge even if the car is not charging. +- **switch.predbat_metric_dynamic_load_adjust** (default `false`) - When On, Predbat cancels a car's charging slots, including the cheap rate of an Octopus Intelligent dispatch, while the car is inside a slot but not actually charging, and restores them as soon as it starts charging again or the slot ends. +This releases the battery hold set by **switch.predbat_car_charging_from_battery**, and stops the house battery being planned around a dispatch Octopus may bill at the full rate. +It uses **car_charging_now** when that is a real sensor (reacting within about 2 minutes), otherwise the house load if the car is inside the CT clamp (about 10 minutes). If turned Off then Predbat won't export during times the car is planned to charge even if the car is not charging. - See [Car charging filtering](#filtering-car-charging-energy-from-house-load) and [Planned car charging](#planned-car-charging) for further car charging setup details. diff --git a/docs/customisation.md b/docs/customisation.md index 941396405..41770f764 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -189,7 +189,11 @@ This setting will not impact the real calculated costs and is only used for plan **switch.predbat_metric_dynamic_load_adjust** (default False) is a toggle that when enabled allows Predbat to take into account your energy consumption within the last 5 minutes. If the load is above what your battery can deliver the plan is updated to predict this load will continue during the current slot, thus preventing forced export in the plan. If the load remains high for two checks in a row, this prediction is extended into the following slot too, so the plan stays up to date across the slot boundary. -If car charging is planned but the load indicates that the car is not charging then Predbat will assume the car will no longer charge during this slot thus allowing the plan to include potential export. +If the current time is inside one of your car's charging slots but the car is not charging, Predbat cancels that slot and every later one, so the battery is no longer held for the car and the plan can include potential export again. +For an Octopus Intelligent dispatch the slot's cheap rate is withdrawn too, as Octopus may bill a dispatch the car does not use at the full rate. +Whether the car is charging is taken from **car_charging_now** when it is set to a real Home Assistant entity, after 2 minutes of it reporting not charging. +Without that sensor, if your car is inside the CT clamp (**switch.predbat_car_energy_reported_load** On), the house load is used instead, after 10 minutes of load too low for a car to be charging. +The slots come back as soon as the car starts charging again or the current slot ends. **input_number.predbat_battery_rate_max_scaling** is a percentage factor to adjust your maximum charge rate from that reported by the inverter. For example, a value of 0.95 would be 95% and indicate charging at 5% slower than reported. From bdbdb81fddd30a9eb9b89a708dd6b9e3f612d287 Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 25 Sep 2026 18:36:08 +0100 Subject: [PATCH 2/7] fix(dynamic-load): address #5229 review findings - A car_charging_now entity HA does not have (deleted, renamed, integration reloading) read as the default "no", i.e. a confirmed stop, and cancelled the car's slots and cheap rate after 2 minutes. It is now read with no default, so it is no evidence, as an "unavailable" reading already was. - The feed-discount strip covered a dispatch's raw boundaries, while the feed marks whole 30 minute rate periods and rate_add_io_slots() rounds out to them, so the edges of a dispatch off the half hour kept their discount. It now rounds out to whole periods, for the trusted car's shield too. - A compare.py run (save=False) applied the last live cancellation even if the car had since started charging or left its slot. It now re-checks both, without advancing the saved state. - The slot-cancellation loop indexed car_charging_slots without the bounds check every other access has. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/octopus.py | 6 +++ apps/predbat/plan.py | 10 +++-- apps/predbat/tests/test_dynamic_load_car.py | 48 +++++++++++++++++++++ 3 files changed, 61 insertions(+), 3 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index b74f85cd2..e1ad2e0a4 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3714,12 +3714,18 @@ def dynamic_load_car_strip_feed_rates(self, rates): if not cancelled_cars or not self.io_adjusted: return rates + # Rounded out to whole 30 minute rate periods: the feed marks and prices whole periods, and + # rate_add_io_slots() likewise treats any overlap with a period as the whole period cancelled_minutes = set() trusted_minutes = set() for car_n in range(min(self.num_cars, len(self.octopus_slots))): covered = cancelled_minutes if car_n in cancelled_cars else trusted_minutes for slot in self.octopus_slots[car_n]: start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True) + if start_minutes == end_minutes: + continue + start_minutes = (start_minutes // 30) * 30 + end_minutes = ((end_minutes + 29) // 30) * 30 covered.update(range(max(start_minutes, self.minutes_now), end_minutes)) window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"] diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index a21bbffe6..1dc672fbb 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -346,7 +346,9 @@ def dynamic_load_car_evidence(self, car_n): is skipped just after midnight, when the load_today sensor resets. """ if is_entity_id(self.get_arg("car_charging_now", None, indirect=False, index=car_n)): - raw = self.get_arg("car_charging_now", "no", index=car_n) + # No default: an entity HA does not have (deleted, renamed, integration reloading) must read + # as no evidence, not resolve to a "no" that looks like the car has stopped + raw = self.get_arg("car_charging_now", None, index=car_n) if raw is None or (isinstance(raw, str) and raw.lower() in ("unknown", "unavailable")): return None, DYNAMIC_LOAD_CAR_SENSOR_MINUTES if isinstance(raw, str): @@ -421,9 +423,11 @@ def dynamic_load_car_check(self, save=True, late=False): self.log("Dynamic load: car {} {}".format(car_n, "is in a charging slot but not charging, cancelling its slots" if cancelled else "slots resumed")) self.dynamic_load_car_cancelled[car_n] = cancelled else: - cancelled = was_cancelled and self.metric_dynamic_load_adjust + # Not advanced, but not applied blindly either: a car that has since started charging + # again or left its slot is no longer cancelled, whatever the last live cycle decided + cancelled = was_cancelled and self.metric_dynamic_load_adjust and self.dynamic_load_car_in_slot(car_n, self.minutes_now) and self.dynamic_load_car_evidence(car_n)[0] is not False - if cancelled: + if cancelled and car_n < len(self.car_charging_slots): for slot in self.car_charging_slots[car_n]: if slot["end"] > self.minutes_now: slot["kwh"] = 0 diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py index 6ae84079d..ad8e6e31d 100644 --- a/apps/predbat/tests/test_dynamic_load_car.py +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -171,6 +171,16 @@ def _run(my_predbat): failed |= _check("t4 unknown keeps cancellation", (not changed) and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) # A static literal is not a sensor, and without the CT clamp there is no load evidence either + # A configured entity HA does not have (deleted, renamed, integration reloading) resolves to the + # read's default - that must be no evidence, not a "not charging" reading (#5229 review) + print("Test 4b: a car_charging_now entity missing from HA is no evidence") + _reset(my_predbat) + my_predbat.args["car_charging_now"] = "binary_sensor.car_charging_now_missing" + my_predbat.ha_interface.dummy_items.pop("binary_sensor.car_charging_now_missing", None) + _cycle(my_predbat, 840) + _cycle(my_predbat, 850) + failed |= _check("t4b missing entity", _kwh(my_predbat) == [3.5, 3.5] and my_predbat.dynamic_load_car_since.get(0) is None, "kwh {} since {}".format(_kwh(my_predbat), my_predbat.dynamic_load_car_since.get(0))) + print("Test 5: a literal car_charging_now is not evidence") _reset(my_predbat) my_predbat.args["car_charging_now"] = "off" @@ -240,6 +250,15 @@ def _run(my_predbat): _cycle(my_predbat, 845) _cycle(my_predbat, 850, save=False) failed |= _check("t10 applied when saved state is cancelled", _kwh(my_predbat) == [0, 0], "kwh {}".format(_kwh(my_predbat))) + # A car that has since resumed charging, or left its slot, is not cancelled in a comparison run + # just because the live state has not been re-derived yet (#5229 review) + _sensor(my_predbat, "on") + _cycle(my_predbat, 851, save=False) + failed |= _check("t10 not applied once charging again", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + _sensor(my_predbat, "off") + _cycle(my_predbat, 875, save=False) + failed |= _check("t10 not applied outside the slot", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + failed |= _check("t10 saved state untouched", my_predbat.dynamic_load_car_cancelled.get(0, False), "cancelled {}".format(my_predbat.dynamic_load_car_cancelled)) # Cars planned by Predbat itself get their slots after the rates are built: the late call # decides the cars the early call could not @@ -258,6 +277,22 @@ def _run(my_predbat): changed = my_predbat.dynamic_load_car_check(late=True) failed |= _check("t11 late cancel", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + # car_charging_slots shorter than num_cars must not raise out of the fetch (#5229 review) + print("Test 11b: fewer car slot lists than cars") + _reset(my_predbat) + _sensor(my_predbat, "off") + _at(my_predbat, 840) + my_predbat.num_cars = 2 + my_predbat.dynamic_load_car_cancelled = {1: True} + my_predbat.car_charging_slots = [_slots()] + try: + my_predbat.dynamic_load_car_check(save=False, late=True) + my_predbat.dynamic_load_car_cancelled = {1: True} + my_predbat.dynamic_load_car_check() + my_predbat.dynamic_load_car_check(late=True) + except IndexError as exc: + failed |= _check("t11b no IndexError", False, str(exc)) + failed |= _run_rates(my_predbat) failed |= _run_poll(my_predbat) finally: @@ -325,6 +360,19 @@ def _run_rates(my_predbat): failed |= _check("t13 other car's dispatch kept", rates[910] == 7.0 and my_predbat.io_adjusted.get(910), "rate {}".format(rates[910])) failed |= _check("t13 fixed window kept", rates[1420] == 7.0 and my_predbat.io_adjusted.get(1420), "rate {}".format(rates[1420])) + # The feed marks whole 30 minute rate periods, and rate_add_io_slots() rounds a dispatch out to + # them too - a dispatch off the half hour must strip the whole period (#5229 review) + print("Test 13b: a dispatch off the half hour strips its whole 30 minute periods") + my_predbat.octopus_slots = [[_dispatch(my_predbat, 967, 997)], []] + my_predbat.io_adjusted = {} + for minute in range(960, 1020): + rates[minute] = 7.0 + my_predbat.io_adjusted[minute] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + failed |= _check("t13b leading edge stripped", rates[962] == 30.0, "rate {}".format(rates[962])) + failed |= _check("t13b trailing edge stripped", rates[1010] == 30.0, "rate {}".format(rates[1010])) + my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]] + my_predbat.dynamic_load_car_cancelled = {} rates[850] = 7.0 my_predbat.io_adjusted[850] = True From 87e09207264b0359305d4632a101f1823caf189e Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 25 Sep 2026 18:41:57 +0100 Subject: [PATCH 3/7] test(dynamic-load): a cancelled dispatch straddling 23:30 keeps the off-peak rate inside the window Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/tests/test_dynamic_load_car.py | 25 +++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py index ad8e6e31d..ea25f3d36 100644 --- a/apps/predbat/tests/test_dynamic_load_car.py +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -373,6 +373,31 @@ def _run_rates(my_predbat): failed |= _check("t13b trailing edge stripped", rates[1010] == 30.0, "rate {}".format(rates[1010])) my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]] + # A cancelled car's dispatch that runs into the fixed 23:30-05:30 window: the part outside it goes + # back to the full rate, the part inside keeps the tariff's own off-peak rate through both the + # feed strip and rate_add_io_slots() - which only ever lowers a rate, so skipping it changes nothing + print("Test 13c: a cancelled dispatch straddling 23:30 keeps the off-peak rate inside the window") + my_predbat.num_cars = 1 + my_predbat.args["octopus_slot_low_rate"] = True + my_predbat.args["octopus_slot_max"] = 48 + my_predbat.rate_min_base = 4.0 + dispatch = [_dispatch(my_predbat, 1380, 1470)] + my_predbat.octopus_slots = [dispatch] + rates = {minute: 25.0 for minute in range(0, 2 * 24 * 60)} + for minute in list(range(1410, 1440)) + list(range(1440, 1440 + 330)): + rates[minute] = 7.0 + # Worst case: the feed flags the whole dispatch as adjusted, including the minutes inside the window + my_predbat.io_adjusted = {} + for minute in range(1380, 1470): + rates[minute] = 7.0 + my_predbat.io_adjusted[minute] = True + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + rates = my_predbat.rate_add_io_slots(0, rates, dispatch) + failed |= _check("t13c 23:00-23:30 back to the full rate", rates[1380] == 30.0 and rates[1409] == 30.0, "rates {} {}".format(rates[1380], rates[1409])) + failed |= _check("t13c 23:30-00:30 still off-peak", all(rates[minute] == 7.0 for minute in range(1410, 1470)), "rates {}".format(sorted(set(rates[minute] for minute in range(1410, 1470))))) + my_predbat.num_cars = 2 + my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]] + my_predbat.dynamic_load_car_cancelled = {} rates[850] = 7.0 my_predbat.io_adjusted[850] = True From bf2abb99644556ed3ec88490d4bdfd156dd4f6ff Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 25 Sep 2026 19:04:04 +0100 Subject: [PATCH 4/7] feat(octopus): octopus_intelligent_trust_slots, and show cancelled car slots with a "?" switch.predbat_octopus_intelligent_trust_slots (expert mode, default On) chooses whether an Octopus Intelligent slot is trusted before the car has been seen charging in it. - On is the existing behaviour: trusted until the car is seen not charging in one. - Off reverses it. An Intelligent car's slots start untrusted: 0 kWh, and no cheap daytime rate from now on. They are trusted as soon as the car is seen charging inside a slot, until it stops for the grace period or the slot ends. Every separate dispatch has to be confirmed again. Off works whether or not dynamic load is on, and never touches slots Predbat planned itself. The overnight rate is left alone, as before. Both directions now come from one function, dynamic_load_car_target(), which returns the state a car should be in: - outside a slot: the default for the switch - inside a slot and charging: trusted - inside a slot, not charging for the grace period: cancelled - otherwise: unchanged The check and the 15 second poll both use it, so the poll fires only when the replan would actually change something. That covers "the car just started charging" under Off. A car with trust Off and nothing that can confirm it charging logs a warning when that set changes. A cancelled slot keeps the kWh it had in a new kwh_cancelled field on the slot itself. It travels with the existing deep copies, and disappears when the slots are rebuilt. The plan shows it in the car column as "3.5?" on pale yellow: in the HTML table, and as car_charging_cancelled in the JSON for the web page. Planned kWh and totals are unchanged. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/config.py | 7 + apps/predbat/fetch.py | 2 + apps/predbat/output.py | 14 +- apps/predbat/plan.py | 154 +++++++++++++----- apps/predbat/predbat.py | 2 + apps/predbat/tests/test_dynamic_load_car.py | 128 +++++++++++++++ .../tests/test_plan_json_rate_adjust.py | 20 +++ apps/predbat/web_helper.py | 3 +- docs/car-charging.md | 11 ++ docs/customisation.md | 1 + 10 files changed, 299 insertions(+), 43 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index ce32a3171..a5753114f 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -915,6 +915,13 @@ "default": False, "enable": "expert_mode", }, + { + "name": "octopus_intelligent_trust_slots", + "friendly_name": "Trust Intelligent slots before the car is seen charging", + "type": "switch", + "default": True, + "enable": "expert_mode", + }, { "name": "car_charging_plan_smart", "friendly_name": "Car Charging Plan Smart", diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 8c0fbfca3..0ffb50b2c 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -3192,8 +3192,10 @@ def fetch_config_options(self): self.octopus_intelligent_charging = self.get_arg("octopus_intelligent_charging") self.octopus_intelligent_ignore_unplugged = self.get_arg("octopus_intelligent_ignore_unplugged") self.octopus_intelligent_consider_full = self.get_arg("octopus_intelligent_consider_full") + self.octopus_intelligent_trust_slots = self.get_arg("octopus_intelligent_trust_slots") self.car_energy_reported_load = self.get_arg("car_energy_reported_load") self.get_car_charging_planned() + self.dynamic_load_car_check_config() self.load_inday_adjustment = 1.0 self.combine_rate_threshold = self.get_arg("combine_rate_threshold") diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 6b07344f4..2c59df496 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -1602,6 +1602,7 @@ def import_rate_color(rate): # Car charging? car_rate = None + car_charging_cancelled = 0.0 if self.num_cars > 0: car_charging_kwh = self.car_charge_slot_kwh(minute_start, minute_end) car_total += car_charging_kwh @@ -1610,8 +1611,15 @@ def import_rate_color(rate): car_color = "FFFF00" car_rate = self.car_charge_slot_rate(minute_start, minute_end) else: - car_charging_str = "⚊" - car_color = "#FFFFFF" + # A slot dynamic load cancelled (the car is not charging) is not planned for, but is + # still shown with a "?" so the car's own plan stays visible + car_charging_cancelled = self.car_charge_slot_kwh_cancelled(minute_start, minute_end) + if car_charging_cancelled > 0.0: + car_charging_str = str(car_charging_cancelled) + "?" + car_color = "#FFFFCC" + else: + car_charging_str = "⚊" + car_color = "#FFFFFF" # The car's own rate can diverge from the general household rate once its IOG dispatch # cap is used up for the day - the car falls back to the peak rate while the house keeps @@ -1810,6 +1818,8 @@ def import_rate_color(rate): json_row["extra_color"] = extra_color if self.num_cars > 0: json_row["car_charging"] = car_charging_kwh + if car_charging_cancelled > 0.0: + json_row["car_charging_cancelled"] = car_charging_cancelled json_row["car_color"] = car_color json_row["car_rate"] = car_rate json_row["car_rate_color"] = car_rate_color if rate_split else None diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 1dc672fbb..88b53a45d 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -372,9 +372,79 @@ def dynamic_load_car_in_slot(self, car_n, minute): return True return False + def dynamic_load_car_trusted_by_default(self, car_n): + """ + Whether car_n's slots are trusted while there is no evidence about them - outside a slot, or + before the car has been seen either way inside one. + + Everything is trusted unless octopus_intelligent_trust_slots is Off, which reverses that for a car + carrying Octopus Intelligent dispatch slots: they are assumed not to happen until the car is seen + charging in one. Slots Predbat plans itself are always trusted by default. + """ + if self.octopus_intelligent_trust_slots: + return True + slots = self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else [] + return not any(slot.get("octopus", False) for slot in slots) + + def dynamic_load_car_active(self, car_n): + """ + Whether car-charging detection applies to car_n: dynamic load is on, or the car's Octopus + Intelligent slots are untrusted by default - which means nothing without the detection that + trusts them again, so it works whether dynamic load is on or not. + """ + return self.metric_dynamic_load_adjust or not self.dynamic_load_car_trusted_by_default(car_n) + + def dynamic_load_car_target(self, car_n, minute, now, record=True): + """ + Whether car_n's slots should be cancelled at minute (floored, as minutes_now) and now (exact). + + - Detection not active for the car: never cancelled. + - Outside any of the car's slots: the default - trusted, unless octopus_intelligent_trust_slots + is Off for an Octopus car (see dynamic_load_car_trusted_by_default()). + - Inside a slot, charging: trusted, straight away. + - Inside a slot, not charging for the grace period (see dynamic_load_car_evidence()): cancelled. + - Otherwise (still inside the grace period, or no evidence): unchanged, starting from the default. + + With record True the "not charging since" clock is started and cleared; with record False + (compare.py runs) it is only read. + """ + default_cancelled = not self.dynamic_load_car_trusted_by_default(car_n) + cancelled = self.dynamic_load_car_cancelled.get(car_n, default_cancelled) + if not self.dynamic_load_car_active(car_n) or not self.dynamic_load_car_in_slot(car_n, minute): + if record: + self.dynamic_load_car_since.pop(car_n, None) + return default_cancelled and self.dynamic_load_car_active(car_n) + + not_charging, grace_minutes = self.dynamic_load_car_evidence(car_n) + if not_charging is False: + if record: + self.dynamic_load_car_since.pop(car_n, None) + return False + if not_charging: + since = self.dynamic_load_car_since.setdefault(car_n, now) if record else self.dynamic_load_car_since.get(car_n) + if since is not None and (now - since).total_seconds() >= grace_minutes * 60: + return True + return cancelled + + def dynamic_load_car_check_config(self): + """ + With octopus_intelligent_trust_slots Off, list the cars whose Octopus Intelligent slots can + never be trusted: no car_charging_now entity and not inside the CT clamp, so nothing can ever + show the car charging. Logged as a warning whenever that list changes, not every cycle. + """ + cars = [] + if not self.octopus_intelligent_trust_slots and self.octopus_intelligent_charging and not self.car_energy_reported_load: + cars = [car_n for car_n in range(self.num_cars) if not is_entity_id(self.get_arg("car_charging_now", None, indirect=False, index=car_n))] + if cars != self.dynamic_load_car_warned: + if cars: + self.log("Warn: octopus_intelligent_trust_slots is Off but car(s) {} have no car_charging_now sensor and are outside the CT clamp, so their Intelligent slots will never be trusted".format(cars)) + self.dynamic_load_car_warned = cars + return cars + def dynamic_load_car_check(self, save=True, late=False): """ - Cancel the slots of a car that is inside one of its charging slots but not charging. + Cancel the slots of a car that is inside one of its charging slots but not charging - or, with + octopus_intelligent_trust_slots Off, of an Octopus Intelligent car until it is seen charging. Runs in fetch_sensor_data() before the rates are built, so that a cancelled Octopus Intelligent dispatch never gets its cheap rate (rate_add_io_slots() and dynamic_load_car_strip_feed_rates() @@ -382,13 +452,13 @@ def dynamic_load_car_check(self, save=True, late=False): rates, from the low-rate scan, so a second call with late=True decides those once they exist - they have no dispatch discount to withhold. - A car that has shown "not charging" for its grace period (see dynamic_load_car_evidence()) has - every slot ending after now set to 0 kWh, which releases "Hold for car" and the predicted car - load. That is re-derived every cycle from freshly built slots, so the cancellation ends as soon - as the car leaves its slots or starts charging again. + A cancelled car has every slot ending after now set to 0 kWh, which releases "Hold for car" and the + predicted car load; the kWh it had is kept in the slot's kwh_cancelled so the plan can still show + it. See dynamic_load_car_target() for the decision. It is re-derived every cycle from freshly + built slots, so nothing needs clearing when a cancellation ends. - With save False (compare.py re-running the fetch for another tariff) the saved decision is - applied but not advanced. + With save False (compare.py re-running the fetch for another tariff) the decision is re-derived + for the current slot and reading, but the saved state is not advanced. Returns True when a car's cancellation changed, so the plan is recomputed. """ @@ -403,33 +473,25 @@ def dynamic_load_car_check(self, save=True, late=False): continue self.dynamic_load_car_decided.add(car_n) - was_cancelled = self.dynamic_load_car_cancelled.get(car_n, False) + cancelled = self.dynamic_load_car_target(car_n, self.minutes_now, self.now_utc_real, record=save) if save: - cancelled = was_cancelled - if not self.metric_dynamic_load_adjust or not self.dynamic_load_car_in_slot(car_n, self.minutes_now): - self.dynamic_load_car_since.pop(car_n, None) - cancelled = False - else: - not_charging, grace_minutes = self.dynamic_load_car_evidence(car_n) - if not_charging is False: - self.dynamic_load_car_since.pop(car_n, None) - cancelled = False - elif not_charging: - since = self.dynamic_load_car_since.setdefault(car_n, self.now_utc_real) - if (self.now_utc_real - since).total_seconds() >= grace_minutes * 60: - cancelled = True + was_cancelled = self.dynamic_load_car_cancelled.get(car_n, False) if cancelled != was_cancelled: changed = True - self.log("Dynamic load: car {} {}".format(car_n, "is in a charging slot but not charging, cancelling its slots" if cancelled else "slots resumed")) + if cancelled and self.dynamic_load_car_in_slot(car_n, self.minutes_now): + reason = "is in a charging slot but not charging, cancelling its slots" + elif cancelled: + reason = "has not been seen charging in its Octopus Intelligent slots yet, not trusting them" + else: + reason = "slots resumed" + self.log("Dynamic load: car {} {}".format(car_n, reason)) self.dynamic_load_car_cancelled[car_n] = cancelled - else: - # Not advanced, but not applied blindly either: a car that has since started charging - # again or left its slot is no longer cancelled, whatever the last live cycle decided - cancelled = was_cancelled and self.metric_dynamic_load_adjust and self.dynamic_load_car_in_slot(car_n, self.minutes_now) and self.dynamic_load_car_evidence(car_n)[0] is not False if cancelled and car_n < len(self.car_charging_slots): for slot in self.car_charging_slots[car_n]: if slot["end"] > self.minutes_now: + if slot.get("kwh", 0) > 0: + slot["kwh_cancelled"] = slot["kwh"] slot["kwh"] = 0 return changed @@ -438,13 +500,15 @@ def dynamic_load_car_poll(self, now=None): Called from the 15 second loop between plan cycles: ask for a replan as soon as a car's cancellation is due to change, rather than waiting up to 5 minutes for the next cycle. - Only car_charging_now is polled - the load test needs the fetch. The clock is started here too, - so a slot that begins between cycles is timed from when the car was first seen idle in it. - The real decision is still made by dynamic_load_car_check() in the replan this triggers. + Only cars with a car_charging_now sensor are polled - the load test needs the fetch. The clock is + started here too, so a slot that begins between cycles is timed from when the car was first seen + idle in it. The decision is dynamic_load_car_target(), the same one the replan will make. Returns True when it set update_pending. """ - if not self.metric_dynamic_load_adjust or not self.num_cars or self.midnight_utc is None: + if not self.num_cars or self.midnight_utc is None: + return False + if not self.metric_dynamic_load_adjust and self.octopus_intelligent_trust_slots: return False if now is None: now = datetime.now(self.local_tz) @@ -458,16 +522,8 @@ def dynamic_load_car_poll(self, now=None): for car_n in range(self.num_cars): if not is_entity_id(self.get_arg("car_charging_now", None, indirect=False, index=car_n)): continue - cancelled = self.dynamic_load_car_cancelled.get(car_n, False) - if not self.dynamic_load_car_in_slot(car_n, minute): - due |= cancelled - continue - not_charging, grace_minutes = self.dynamic_load_car_evidence(car_n) - if not_charging is False: - due |= cancelled - elif not_charging and not cancelled: - since = self.dynamic_load_car_since.setdefault(car_n, now) - due |= (now - since).total_seconds() >= grace_minutes * 60 + if self.dynamic_load_car_target(car_n, minute, now) != self.dynamic_load_car_cancelled.get(car_n, False): + due = True if due: self.log("Dynamic load: car charging state changed, will re-plan") @@ -5644,6 +5700,24 @@ def car_charge_slot_kwh(self, minute_start, minute_end): car_charging_kwh = dp2(car_charging_kwh) return car_charging_kwh + def car_charge_slot_kwh_cancelled(self, minute_start, minute_end): + """ + Car charging in kWh that dynamic load has cancelled for the given self.plan_interval_minutes-minute + slot - the car was not charging, so the plan does not count on it, but it is still shown with a "?". + """ + car_charging_kwh = 0.0 + for car_n in range(self.num_cars): + for window in self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else []: + start = window["start"] + end = window["end"] + kwh_cancelled = window.get("kwh_cancelled", 0) + if kwh_cancelled and start < minute_end and end > minute_start and end != start: + kwh = dp2(kwh_cancelled) / (end - start) + for minute_offset in range(minute_start, minute_end, PREDICT_STEP): + if start <= minute_offset < end: + car_charging_kwh += kwh * PREDICT_STEP + return dp2(car_charging_kwh) + def car_charge_slot_rate(self, minute_start, minute_end): """ Work out the car's own effective import rate (p/kWh) for the given diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 2de2e8e7d..e6592f890 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -532,6 +532,7 @@ def reset(self): self.octopus_intelligent_charging = False self.octopus_intelligent_ignore_unplugged = False self.octopus_intelligent_consider_full = False + self.octopus_intelligent_trust_slots = True self.notify_devices = ["notify"] self.octopus_url_cache = {} self.dispatch_timeline_last = {} @@ -548,6 +549,7 @@ def reset(self): self.dynamic_load_car_since = {} self.dynamic_load_car_cancelled = {} self.dynamic_load_car_decided = set() + self.dynamic_load_car_warned = [] self.battery_capacity_nominal = False self.battery_scaling_auto = False self.releases = {} diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py index ea25f3d36..0209a9ba8 100644 --- a/apps/predbat/tests/test_dynamic_load_car.py +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -44,6 +44,7 @@ "dynamic_load_car_since", "dynamic_load_car_cancelled", "dynamic_load_car_decided", + "octopus_intelligent_trust_slots", ) @@ -99,6 +100,7 @@ def _reset(my_predbat): my_predbat.dynamic_load_car_cancelled = {} my_predbat.dynamic_load_car_decided = set() my_predbat.update_pending = False + my_predbat.octopus_intelligent_trust_slots = True def _kwh(my_predbat): @@ -295,6 +297,8 @@ def _run(my_predbat): failed |= _run_rates(my_predbat) failed |= _run_poll(my_predbat) + failed |= _run_untrusted(my_predbat) + failed |= _run_kwh_cancelled(my_predbat) finally: if had_sensor: my_predbat.args["car_charging_now"] = saved_sensor @@ -472,6 +476,130 @@ def _run_poll(my_predbat): return failed +def _cancelled(my_predbat): + """ + kwh_cancelled of car 0's slots after the check (None where a slot was not cancelled). + """ + return [slot.get("kwh_cancelled") for slot in my_predbat.car_charging_slots[0]] + + +def _run_untrusted(my_predbat): + """ + octopus_intelligent_trust_slots Off: Octopus Intelligent slots start untrusted and are only + trusted once the car is seen charging in one, until it stops or the slot ends. + """ + failed = False + try: + print("Test 19: trust Off - Octopus slots start untrusted, even with dynamic load off") + _reset(my_predbat) + _sensor(my_predbat, "off") + my_predbat.metric_dynamic_load_adjust = False + my_predbat.octopus_intelligent_trust_slots = False + changed = _cycle(my_predbat, 830) + failed |= _check("t19 untrusted outside a slot", changed and _kwh(my_predbat) == [0, 0] and _cancelled(my_predbat) == [3.5, 3.5], "changed {} kwh {} cancelled {}".format(changed, _kwh(my_predbat), _cancelled(my_predbat))) + changed = _cycle(my_predbat, 840) + failed |= _check("t19 still untrusted in a slot while not charging", (not changed) and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 20: trust Off - the car seen charging in a slot trusts it and the later ones, straight away") + _sensor(my_predbat, "on") + changed = _cycle(my_predbat, 841) + failed |= _check("t20 trusted on charging", changed and _kwh(my_predbat) == [3.5, 3.5] and _cancelled(my_predbat) == [None, None], "changed {} kwh {} cancelled {}".format(changed, _kwh(my_predbat), _cancelled(my_predbat))) + + print("Test 21: trust Off - the car stopping for the grace period cancels them again") + _sensor(my_predbat, "off") + changed = _cycle(my_predbat, 845) + failed |= _check("t21 inside grace", (not changed) and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 847) + failed |= _check("t21 after grace", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 22: trust Off - leaving the slot goes back to untrusted, even while charging") + _sensor(my_predbat, "on") + _cycle(my_predbat, 850) + changed = _cycle(my_predbat, 875) + failed |= _check("t22 untrusted after the slot", changed and _kwh(my_predbat) == [3.5, 0] and my_predbat.dynamic_load_car_cancelled.get(0), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + + print("Test 23: trust Off does not touch slots Predbat planned itself") + _reset(my_predbat) + _sensor(my_predbat, "off") + my_predbat.metric_dynamic_load_adjust = False + my_predbat.octopus_intelligent_trust_slots = False + own_slots = [{"start": 840, "end": 870, "kwh": 3.5}, {"start": 900, "end": 930, "kwh": 3.5}] + _cycle(my_predbat, 840, slots=own_slots) + _cycle(my_predbat, 850, slots=own_slots) + failed |= _check("t23 own slots untouched", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + print("Test 24: trust Off with no evidence source never trusts the slots") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = False + my_predbat.octopus_intelligent_trust_slots = False + _cycle(my_predbat, 840) + _cycle(my_predbat, 855) + failed |= _check("t24 no evidence stays untrusted", _kwh(my_predbat) == [0, 0], "kwh {}".format(_kwh(my_predbat))) + + print("Test 25: trust Off - the poll asks for a replan when the car starts charging, not when the slot ends") + _reset(my_predbat) + _sensor(my_predbat, "off") + my_predbat.octopus_intelligent_trust_slots = False + _cycle(my_predbat, 840) + my_predbat.update_pending = False + _sensor(my_predbat, "on") + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=842)) + failed |= _check("t25 due on charging", due, "due {}".format(due)) + _cycle(my_predbat, 842) + _sensor(my_predbat, "off") + _cycle(my_predbat, 845) + _cycle(my_predbat, 850) + my_predbat.update_pending = False + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=875)) + failed |= _check("t25 not due leaving the slot already untrusted", not due, "due {}".format(due)) + + print("Test 27: trust Off warns about an Intelligent car with nothing to confirm its slots, once") + saved_iog = my_predbat.octopus_intelligent_charging + try: + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.octopus_intelligent_charging = True + my_predbat.octopus_intelligent_trust_slots = False + my_predbat.car_energy_reported_load = False + my_predbat.dynamic_load_car_warned = [] + cars = my_predbat.dynamic_load_car_check_config() + failed |= _check("t27 no evidence warned", cars == [0] and my_predbat.dynamic_load_car_warned == [0], "cars {}".format(cars)) + my_predbat.car_energy_reported_load = True + failed |= _check("t27 CT clamp is evidence", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned)) + my_predbat.car_energy_reported_load = False + _sensor(my_predbat, "off") + failed |= _check("t27 sensor is evidence", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned)) + _sensor(my_predbat, None) + my_predbat.octopus_intelligent_trust_slots = True + failed |= _check("t27 trust On never warns", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned)) + finally: + my_predbat.octopus_intelligent_charging = saved_iog + my_predbat.dynamic_load_car_warned = [] + finally: + my_predbat.octopus_intelligent_trust_slots = True + return failed + + +def _run_kwh_cancelled(my_predbat): + """ + A cancelled slot keeps the kWh it had in kwh_cancelled, so the plan can still show it with a "?". + """ + failed = False + print("Test 26: a cancelled slot records its kWh in kwh_cancelled") + _reset(my_predbat) + _sensor(my_predbat, "off") + slots = [{"start": 840, "end": 870, "kwh": 3.5}, {"start": 900, "end": 930, "kwh": 0.0}] + _cycle(my_predbat, 840, slots=slots) + _cycle(my_predbat, 845, slots=slots) + failed |= _check("t26 recorded", _kwh(my_predbat) == [0, 0] and _cancelled(my_predbat) == [3.5, None], "kwh {} cancelled {}".format(_kwh(my_predbat), _cancelled(my_predbat))) + failed |= _check("t26 helper", my_predbat.car_charge_slot_kwh_cancelled(840, 870) == 3.5 and my_predbat.car_charge_slot_kwh(840, 870) == 0, "cancelled {}".format(my_predbat.car_charge_slot_kwh_cancelled(840, 870))) + _sensor(my_predbat, "on") + _cycle(my_predbat, 850, slots=slots) + failed |= _check("t26 gone once resumed", _cancelled(my_predbat) == [None, None], "cancelled {}".format(_cancelled(my_predbat))) + return failed + + def test_dynamic_load_car_not_charging(my_predbat): """ Dynamic load cancels the car slots, and their cheap rate, while a car in its slot is not charging. diff --git a/apps/predbat/tests/test_plan_json_rate_adjust.py b/apps/predbat/tests/test_plan_json_rate_adjust.py index 43504d39d..edfad6e65 100644 --- a/apps/predbat/tests/test_plan_json_rate_adjust.py +++ b/apps/predbat/tests/test_plan_json_rate_adjust.py @@ -200,6 +200,26 @@ def run_test_plan_json_rate_adjust(my_predbat): print("ERROR: Expected car_charge_slot_rate to skip a window with no average key, got {}".format(rate)) failed = True + # A slot dynamic load cancelled (car in its slot but not charging) plans 0 kWh but is still shown, + # with a "?", so the user can see the car plan Predbat has decided not to rely on (#5229) + print("Test plan output shows a cancelled car slot with a question mark") + my_predbat.car_charging_slots[0] = [{"start": car_minute, "end": car_minute + 30, "kwh": 0, "kwh_cancelled": 3.0, "average": 10.0, "octopus": True}] + html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False) + car_row = next((row for row in raw_plan["rows"] if row.get("slot_minute") == car_minute), None) + if car_row is None: + print("ERROR: Could not find row for car minute {} in plan output".format(car_minute)) + failed = True + else: + if car_row.get("car_charging") != 0 or car_row.get("car_charging_cancelled") != 3.0: + print("ERROR: Expected car_charging 0 and car_charging_cancelled 3.0, got {} and {}".format(car_row.get("car_charging"), car_row.get("car_charging_cancelled"))) + failed = True + if "3.0?" not in html_plan: + print("ERROR: Expected the cancelled car slot cell '3.0?' on #FFFFCC in the HTML plan") + failed = True + if raw_plan.get("totals", {}).get("car_charging", 0) != 0: + print("ERROR: Cancelled kWh must not count towards the planned car total, got {}".format(raw_plan.get("totals", {}).get("car_charging"))) + failed = True + # Clean up my_predbat.num_cars = 0 my_predbat.car_charging_slots[0] = [] diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index c96267c34..03222adc1 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -7129,7 +7129,8 @@ def get_plan_renderer_js(): // Car charging (conditional) if (jsonData.num_cars > 0) { - const carVal = row.car_charging > 0 ? row.car_charging : '⚊'; + // A slot dynamic load cancelled (car not charging) still shows its kWh, with a "?" + const carVal = row.car_charging > 0 ? row.car_charging : (row.car_charging_cancelled > 0 ? `${row.car_charging_cancelled}?` : '⚊'); html += `${carVal}`; } diff --git a/docs/car-charging.md b/docs/car-charging.md index a66ad2379..c8cce179a 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -478,6 +478,17 @@ Predbat will still assume all Octopus charging slots are low rates even if some - The switch **switch.predbat_octopus_intelligent_ignore_unplugged** (*expert mode*) (default value is Off) can be used to prevent Predbat from assuming the car will be charging or that future extra low-rate slots apply when the car is unplugged. This will only work correctly if **car_charging_planned** is set correctly in `apps.yaml` to detect your car being plugged in +- The switch **switch.predbat_octopus_intelligent_trust_slots** (*expert mode*) (default value is On) controls whether Predbat believes an Intelligent charging slot will happen before your car has actually been seen charging in it. +When On, every slot is trusted until the car is seen not charging in one (see **switch.predbat_metric_dynamic_load_adjust** below). +When Off, Intelligent slots are assumed not to happen: the car's charging is not predicted and a daytime slot's low rate is not used for the house battery, until the car is seen charging inside a slot. +From then on that slot and the later ones are trusted, until the car stops charging or the slot ends, after which the next slot has to be confirmed again. +The overnight 23:30-05:30 rate stays low either way, as that is part of the tariff. +Whether the car is charging comes from **car_charging_now** when it is set to a real sensor, otherwise from the house load if your car is inside the CT clamp (**switch.predbat_car_energy_reported_load**). +With neither, the car's slots can never be confirmed, and Predbat logs a warning. +This switch works whether or not dynamic load adjustment is turned on. + +Slots that Predbat has decided will not happen are still shown in the car column of the plan, with a question mark after the kWh (e.g. **3.5?**), so you can see the car's own schedule even though the plan is not counting on it. + - Let the Octopus app control when your car charges. #### Reading the dispatch timeline in the logs diff --git a/docs/customisation.md b/docs/customisation.md index 41770f764..a5d0e8d09 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -306,6 +306,7 @@ These are described in detail in [Car Charging](car-charging.md) and are listed - **input_number.predbat_car_charging_loss** - percentage energy lost when charging the car - **switch.predbat_octopus_intelligent_charging** - controls whether Octopus Intelligent (via the Octopus Energy integration) controls the car charging or Predbat plans the car charging - **switch.predbat_octopus_intelligent_ignore_unplugged** (_expert mode_) - used with Octopus Intelligent to prevent Predbat from assuming the car will be charging when the car is unplugged +- **switch.predbat_octopus_intelligent_trust_slots** (_expert mode_) - when Off, Octopus Intelligent slots are assumed not to happen until the car is seen charging in one, see [Car charging](car-charging.md) - **binary_sensor.predbat_car_charging_slot** - set to On by Predbat when the car should be charged (Predbat-led charging) - **select.predbat_car_charging_plan_time** - the time you want the car to be charged by - **switch.predbat_car_charging_plan_smart** - allows Predbat to allocate car charging slots to the cheapest times rather than all low-rate slots From 872fd75d42027a1e86b1c7aff970259a43a3df73 Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 25 Sep 2026 20:25:46 +0100 Subject: [PATCH 5/7] fix(dynamic-load): address #5229 review round 2 - The 15 second poll read car_charging_now through get_arg(index=car_n). For a second car sharing a single (non-list) sensor, that logs a set-up warning every time. Which cars have a real sensor entity is now worked out once per cycle (dynamic_load_car_refresh_sensors(), from fetch_config_options()), and the cached entity is read directly. - A car cancelled in the same step as another car's live charging lost its "?": the cancelled kWh was only looked up when no car was charging. It is now always computed, and shown as "2.0 +3.0?" in the HTML table, the JSON and the web page. - octopus_intelligent_trust_slots Off only acts on the car plan that Octopus Intelligent charging builds. With octopus_intelligent_charging Off, the dispatch rates are still used, so the docs now say so and a warning is logged. - load_last_car_slot and the slot walk that set it are removed. They have been dead since the car cancellation moved out of dynamic_load(). Test 18b pins the poll across midnight: it reads the previous cycle's slots and its midnight_utc together, so a new-day slot lines up with the poll's minute. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/output.py | 20 +++--- apps/predbat/plan.py | 48 ++++++++----- apps/predbat/predbat.py | 3 +- apps/predbat/tests/test_dynamic_load.py | 5 -- apps/predbat/tests/test_dynamic_load_car.py | 68 +++++++++++++++++++ .../tests/test_plan_json_rate_adjust.py | 19 ++++++ apps/predbat/web_helper.py | 3 +- docs/car-charging.md | 1 + 8 files changed, 134 insertions(+), 33 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 2c59df496..df2f52687 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -1606,20 +1606,22 @@ def import_rate_color(rate): if self.num_cars > 0: car_charging_kwh = self.car_charge_slot_kwh(minute_start, minute_end) car_total += car_charging_kwh + # A slot dynamic load cancelled (the car is not charging) is not planned for, but is still + # shown with a "?" so the car's own plan stays visible - alongside another car's live + # charging in the same step, not only when no car is charging + car_charging_cancelled = self.car_charge_slot_kwh_cancelled(minute_start, minute_end) if car_charging_kwh > 0.0: car_charging_str = str(car_charging_kwh) + if car_charging_cancelled > 0.0: + car_charging_str += " +" + str(car_charging_cancelled) + "?" car_color = "FFFF00" car_rate = self.car_charge_slot_rate(minute_start, minute_end) + elif car_charging_cancelled > 0.0: + car_charging_str = str(car_charging_cancelled) + "?" + car_color = "#FFFFCC" else: - # A slot dynamic load cancelled (the car is not charging) is not planned for, but is - # still shown with a "?" so the car's own plan stays visible - car_charging_cancelled = self.car_charge_slot_kwh_cancelled(minute_start, minute_end) - if car_charging_cancelled > 0.0: - car_charging_str = str(car_charging_cancelled) + "?" - car_color = "#FFFFCC" - else: - car_charging_str = "⚊" - car_color = "#FFFFFF" + car_charging_str = "⚊" + car_color = "#FFFFFF" # The car's own rate can diverge from the general household rate once its IOG dispatch # cap is used up for the day - the car falls back to the peak rate while the house keeps diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 88b53a45d..04f843fad 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -245,17 +245,6 @@ def dynamic_load(self): ) self.log("Dynamic load last period {:.2f}kW, status {}, threshold_battery {}kWh, threshold_car {}kWh,".format(self.load_last_period, self.load_last_status, threshold_battery, dp1(threshold_car))) - # Is the car currently planned to charge? - load_car_slot = False - if self.car_energy_reported_load: - for car_n in range(0, self.num_cars): - for slot_n in range(0, len(self.car_charging_slots[car_n])): - slot = self.car_charging_slots[car_n][slot_n] - # Don't include the exact start minute as it may take a few for the load to filter through - if slot["start"] <= self.minutes_now < slot["end"]: - load_car_slot = True - self.log("Dynamic load adjust sees car {} charging now slot {}-{}, previous car slot {}".format(car_n, slot["start"], slot["end"], self.load_last_car_slot)) - self.load_last_car_slot = load_car_slot self.dynamic_load_baseline = {} # Dynamic load baselines are stored as kWh per PREDICT_STEP. When the car is inside the @@ -345,10 +334,14 @@ def dynamic_load_car_evidence(self, car_n): (car_energy_reported_load): otherwise its charging never shows in the load at all. The load test is skipped just after midnight, when the load_today sensor resets. """ - if is_entity_id(self.get_arg("car_charging_now", None, indirect=False, index=car_n)): - # No default: an entity HA does not have (deleted, renamed, integration reloading) must read - # as no evidence, not resolve to a "no" that looks like the car has stopped - raw = self.get_arg("car_charging_now", None, index=car_n) + entity_id = self.dynamic_load_car_sensors.get(car_n) + if entity_id: + # Read the entity cached by dynamic_load_car_refresh_sensors() rather than get_arg(index=...): + # the poll calls this every 15 seconds, and an indexed read of a single (non-list) sensor + # shared by several cars logs a set-up warning each time. No default: an entity HA does not + # have (deleted, renamed, integration reloading) must read as no evidence, not resolve to a + # "no" that looks like the car has stopped. + raw = self.resolve_arg("car_charging_now", entity_id, default=None) if raw is None or (isinstance(raw, str) and raw.lower() in ("unknown", "unavailable")): return None, DYNAMIC_LOAD_CAR_SENSOR_MINUTES if isinstance(raw, str): @@ -426,15 +419,36 @@ def dynamic_load_car_target(self, car_n, minute, now, record=True): return True return cancelled + def dynamic_load_car_refresh_sensors(self): + """ + Work out, once per cycle, which cars have car_charging_now set to a real entity, keeping the + entity id for dynamic_load_car_evidence(). A static literal in apps.yaml can never report the car + stopping, so it does not count. + """ + self.dynamic_load_car_sensors = {} + for car_n in range(self.num_cars): + entity_id = self.get_arg("car_charging_now", None, indirect=False, index=car_n) + if is_entity_id(entity_id): + self.dynamic_load_car_sensors[car_n] = entity_id + def dynamic_load_car_check_config(self): """ With octopus_intelligent_trust_slots Off, list the cars whose Octopus Intelligent slots can never be trusted: no car_charging_now entity and not inside the CT clamp, so nothing can ever show the car charging. Logged as a warning whenever that list changes, not every cycle. """ + self.dynamic_load_car_refresh_sensors() + iog_off = (not self.octopus_intelligent_trust_slots) and (not self.octopus_intelligent_charging) and ("octopus_intelligent_slot" in self.args) + if iog_off != self.dynamic_load_car_warned_iog_off: + if iog_off: + # Off only governs the slots Octopus Intelligent charging turns into the car plan; with that + # off the dispatches still feed the rate overlay, which this switch does not touch + self.log("Warn: octopus_intelligent_trust_slots is Off but octopus_intelligent_charging is Off too, so it has no effect - the Intelligent dispatch rates are still used") + self.dynamic_load_car_warned_iog_off = iog_off + cars = [] if not self.octopus_intelligent_trust_slots and self.octopus_intelligent_charging and not self.car_energy_reported_load: - cars = [car_n for car_n in range(self.num_cars) if not is_entity_id(self.get_arg("car_charging_now", None, indirect=False, index=car_n))] + cars = [car_n for car_n in range(self.num_cars) if car_n not in self.dynamic_load_car_sensors] if cars != self.dynamic_load_car_warned: if cars: self.log("Warn: octopus_intelligent_trust_slots is Off but car(s) {} have no car_charging_now sensor and are outside the CT clamp, so their Intelligent slots will never be trusted".format(cars)) @@ -520,7 +534,7 @@ def dynamic_load_car_poll(self, now=None): due = False for car_n in range(self.num_cars): - if not is_entity_id(self.get_arg("car_charging_now", None, indirect=False, index=car_n)): + if car_n not in self.dynamic_load_car_sensors: continue if self.dynamic_load_car_target(car_n, minute, now) != self.dynamic_load_car_cancelled.get(car_n, False): due = True diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index e6592f890..d98c0c888 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -545,11 +545,12 @@ def reset(self): self.load_minutes_age = 0 self.load_last_period = 0 self.load_last_status = "baseline" - self.load_last_car_slot = False self.dynamic_load_car_since = {} self.dynamic_load_car_cancelled = {} self.dynamic_load_car_decided = set() self.dynamic_load_car_warned = [] + self.dynamic_load_car_warned_iog_off = False + self.dynamic_load_car_sensors = {} self.battery_capacity_nominal = False self.battery_scaling_auto = False self.releases = {} diff --git a/apps/predbat/tests/test_dynamic_load.py b/apps/predbat/tests/test_dynamic_load.py index 3ee59d75a..706f25a7e 100644 --- a/apps/predbat/tests/test_dynamic_load.py +++ b/apps/predbat/tests/test_dynamic_load.py @@ -29,7 +29,6 @@ def test_dynamic_load_car_slot_cancellation(my_predbat): my_predbat.car_charging_threshold = 3.0 # 3kW my_predbat.metric_dynamic_load_adjust = True my_predbat.load_last_status = "baseline" # Initialise status - my_predbat.load_last_car_slot = False # Test 1: High load - should set load_last_status to "high" but not cancel slots print("Test 1: High load case") @@ -67,7 +66,6 @@ def test_dynamic_load_car_slot_cancellation(my_predbat): print("Test 2: Low load case leaves the car slots to dynamic_load_car_check()") my_predbat.load_last_status = "baseline" # Reset status my_predbat.load_last_period = 2.0 # 2kW - low load (< battery_rate_max_discharge * 0.9 * MINUTE_WATT / 1000 and < car_charging_threshold * 0.9) - my_predbat.load_last_car_slot = True my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 25, "kwh": 10.0}] my_predbat.car_charging_slots[1] = [{"start": my_predbat.minutes_now + 30, "end": my_predbat.minutes_now + 45, "kwh": 8.0}] @@ -125,7 +123,6 @@ def test_dynamic_load_high_load_baseline(my_predbat): "car_charging_energy", "load_last_period", "load_last_status", - "load_last_car_slot", "metric_dynamic_load_adjust", "dynamic_load_baseline", ) @@ -153,7 +150,6 @@ def _run_dynamic_load_high_load_baseline(my_predbat): my_predbat.car_charging_threshold = 3.0 my_predbat.metric_dynamic_load_adjust = True my_predbat.load_last_status = "baseline" - my_predbat.load_last_car_slot = False my_predbat.load_last_period = 6.0 # 6kW - above the 5kW battery threshold, so "high" minutes_end_slot = my_predbat.minutes_now + my_predbat.plan_interval_minutes @@ -239,7 +235,6 @@ def _run_dynamic_load_high_load_baseline(my_predbat): print("Test 5: Measured car energy is excluded from the next-slot baseline") my_predbat.load_last_period = 7.2 # 6kW car plus 1.2kW house load my_predbat.load_last_status = "high" # Treat this as the second consecutive high reading - my_predbat.load_last_car_slot = True my_predbat.car_energy_reported_load = True my_predbat.car_charging_hold = True my_predbat.car_charging_energy = {0: 0.5} # 0.5kWh measured in the latest 5-minute period diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py index 0209a9ba8..c8fd5a59b 100644 --- a/apps/predbat/tests/test_dynamic_load_car.py +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -44,6 +44,9 @@ "dynamic_load_car_since", "dynamic_load_car_cancelled", "dynamic_load_car_decided", + "dynamic_load_car_sensors", + "dynamic_load_car_warned", + "dynamic_load_car_warned_iog_off", "octopus_intelligent_trust_slots", ) @@ -81,9 +84,11 @@ def _sensor(my_predbat, state): """ if state is None: my_predbat.args.pop("car_charging_now", None) + my_predbat.dynamic_load_car_refresh_sensors() return my_predbat.args["car_charging_now"] = SENSOR my_predbat.ha_interface.dummy_items[SENSOR] = state + my_predbat.dynamic_load_car_refresh_sensors() def _reset(my_predbat): @@ -179,6 +184,7 @@ def _run(my_predbat): _reset(my_predbat) my_predbat.args["car_charging_now"] = "binary_sensor.car_charging_now_missing" my_predbat.ha_interface.dummy_items.pop("binary_sensor.car_charging_now_missing", None) + my_predbat.dynamic_load_car_refresh_sensors() _cycle(my_predbat, 840) _cycle(my_predbat, 850) failed |= _check("t4b missing entity", _kwh(my_predbat) == [3.5, 3.5] and my_predbat.dynamic_load_car_since.get(0) is None, "kwh {} since {}".format(_kwh(my_predbat), my_predbat.dynamic_load_car_since.get(0))) @@ -186,6 +192,7 @@ def _run(my_predbat): print("Test 5: a literal car_charging_now is not evidence") _reset(my_predbat) my_predbat.args["car_charging_now"] = "off" + my_predbat.dynamic_load_car_refresh_sensors() my_predbat.load_last_period = 0.2 _cycle(my_predbat, 840) _cycle(my_predbat, 860) @@ -466,6 +473,41 @@ def _run_poll(my_predbat): due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=875, seconds=5)) failed |= _check("t18 reset once minutes_now leaves it", due, "due {}".format(due)) + # Between cycles the poll reads the previous cycle's slots and its midnight_utc together, so after + # midnight a new-day slot built at 23:55 (minute 1440 on that axis) is where the poll's minute lands + print("Test 18b: the poll across midnight uses the same axis as the slots it reads") + _reset(my_predbat) + _sensor(my_predbat, "off") + midnight_slots = [{"start": 1440, "end": 1470, "kwh": 3.5, "octopus": True}] + _cycle(my_predbat, 1435, slots=midnight_slots) + my_predbat.update_pending = False + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=1440, seconds=30)) + failed |= _check("t18b not due at 00:00:30", not due, "due {}".format(due)) + failed |= _check("t18b clock started in the new-day slot", my_predbat.dynamic_load_car_since.get(0) is not None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) + due = my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=1442, seconds=40)) + failed |= _check("t18b due after the grace in the new-day slot", due, "due {}".format(due)) + + # The poll runs every 15 seconds: it must not read car_charging_now through get_arg(index=...), + # which warns every time for a second car sharing a single (non-list) sensor (#5229 review) + print("Test 18c: the poll does not re-resolve car_charging_now for every car every 15 seconds") + _reset(my_predbat) + _sensor(my_predbat, "off") + my_predbat.num_cars = 2 + my_predbat.dynamic_load_car_refresh_sensors() + _at(my_predbat, 840) + my_predbat.car_charging_slots = [_slots(), _slots()] + my_predbat.dynamic_load_car_check() + logged = [] + saved_log = my_predbat.log + my_predbat.log = lambda message, *args, **kwargs: logged.append(message) + try: + my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=841)) + my_predbat.dynamic_load_car_poll(now=my_predbat.midnight_utc + timedelta(minutes=841, seconds=15)) + finally: + my_predbat.log = saved_log + warnings = [message for message in logged if "incorrectly setup" in message] + failed |= _check("t18c no per-poll config warnings", not warnings, "logged {}".format(warnings)) + print("Test 17: the poll does nothing with dynamic load off") _reset(my_predbat) _sensor(my_predbat, "off") @@ -573,6 +615,32 @@ def _run_untrusted(my_predbat): _sensor(my_predbat, None) my_predbat.octopus_intelligent_trust_slots = True failed |= _check("t27 trust On never warns", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned)) + + # Off only governs slots Octopus Intelligent charging turns into the car plan - with it off, + # the dispatch rate overlay is untouched, so say so rather than silently not applying (#5229 review) + print("Test 27b: trust Off with Octopus Intelligent charging off warns that it has no effect") + _sensor(my_predbat, "off") + my_predbat.octopus_intelligent_trust_slots = False + my_predbat.octopus_intelligent_charging = False + had_slot_sensor = "octopus_intelligent_slot" in my_predbat.args + saved_slot_sensor = my_predbat.args.get("octopus_intelligent_slot") + my_predbat.args["octopus_intelligent_slot"] = "binary_sensor.octopus_intelligent_slot_test" + logged = [] + saved_log = my_predbat.log + my_predbat.log = lambda message, *args, **kwargs: logged.append(message) + try: + my_predbat.dynamic_load_car_warned_iog_off = False + my_predbat.dynamic_load_car_check_config() + first = [message for message in logged if "octopus_intelligent_charging" in message] + my_predbat.dynamic_load_car_check_config() + again = [message for message in logged if "octopus_intelligent_charging" in message] + finally: + my_predbat.log = saved_log + if had_slot_sensor: + my_predbat.args["octopus_intelligent_slot"] = saved_slot_sensor + else: + my_predbat.args.pop("octopus_intelligent_slot", None) + failed |= _check("t27b warned once", len(first) == 1 and len(again) == 1, "logged {}".format(logged)) finally: my_predbat.octopus_intelligent_charging = saved_iog my_predbat.dynamic_load_car_warned = [] diff --git a/apps/predbat/tests/test_plan_json_rate_adjust.py b/apps/predbat/tests/test_plan_json_rate_adjust.py index edfad6e65..951df75cc 100644 --- a/apps/predbat/tests/test_plan_json_rate_adjust.py +++ b/apps/predbat/tests/test_plan_json_rate_adjust.py @@ -220,6 +220,25 @@ def run_test_plan_json_rate_adjust(my_predbat): print("ERROR: Cancelled kWh must not count towards the planned car total, got {}".format(raw_plan.get("totals", {}).get("car_charging"))) failed = True + # Two cars in the same step, one charging and one cancelled: the cancelled car's "?" must still show + # alongside the live kWh, not be dropped because the row has some live car charging (#5229 review) + print("Test plan output shows a cancelled car slot next to another car's live charging") + saved_num_cars = my_predbat.num_cars + my_predbat.num_cars = 2 + while len(my_predbat.car_charging_slots) < 2: + my_predbat.car_charging_slots.append([]) + my_predbat.car_charging_slots[1] = [{"start": car_minute, "end": car_minute + 30, "kwh": 2.0, "average": 10.0, "octopus": True}] + html_plan, raw_plan = my_predbat.publish_html_plan(pv_step, pv_step, load_step, load_step, my_predbat.end_record, publish=False) + car_row = next((row for row in raw_plan["rows"] if row.get("slot_minute") == car_minute), None) + if car_row is None or car_row.get("car_charging") != 2.0 or car_row.get("car_charging_cancelled") != 3.0: + print("ERROR: Expected car_charging 2.0 and car_charging_cancelled 3.0, got {}".format(car_row and (car_row.get("car_charging"), car_row.get("car_charging_cancelled")))) + failed = True + if ">2.0 +3.0?" not in html_plan: + print("ERROR: Expected the car cell to show the live 2.0 and the cancelled +3.0?") + failed = True + my_predbat.car_charging_slots[1] = [] + my_predbat.num_cars = saved_num_cars + # Clean up my_predbat.num_cars = 0 my_predbat.car_charging_slots[0] = [] diff --git a/apps/predbat/web_helper.py b/apps/predbat/web_helper.py index 03222adc1..f9a5681d9 100644 --- a/apps/predbat/web_helper.py +++ b/apps/predbat/web_helper.py @@ -7130,7 +7130,8 @@ def get_plan_renderer_js(): // Car charging (conditional) if (jsonData.num_cars > 0) { // A slot dynamic load cancelled (car not charging) still shows its kWh, with a "?" - const carVal = row.car_charging > 0 ? row.car_charging : (row.car_charging_cancelled > 0 ? `${row.car_charging_cancelled}?` : '⚊'); + const carCancelled = row.car_charging_cancelled > 0 ? `${row.car_charging_cancelled}?` : ''; + const carVal = row.car_charging > 0 ? (carCancelled ? `${row.car_charging} +${carCancelled}` : row.car_charging) : (carCancelled || '⚊'); html += `${carVal}`; } diff --git a/docs/car-charging.md b/docs/car-charging.md index c8cce179a..dec879dde 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -486,6 +486,7 @@ The overnight 23:30-05:30 rate stays low either way, as that is part of the tari Whether the car is charging comes from **car_charging_now** when it is set to a real sensor, otherwise from the house load if your car is inside the CT clamp (**switch.predbat_car_energy_reported_load**). With neither, the car's slots can never be confirmed, and Predbat logs a warning. This switch works whether or not dynamic load adjustment is turned on. +It only applies when **switch.predbat_octopus_intelligent_charging** is On, as it acts on the car plan that Octopus Intelligent charging builds. With that Off, the Intelligent dispatch rates are used as before and Predbat logs a warning. Slots that Predbat has decided will not happen are still shown in the car column of the plan, with a question mark after the kWh (e.g. **3.5?**), so you can see the car's own schedule even though the plan is not counting on it. From 7f5a7516f8d8cb737b141b0f952e76d302343200 Mon Sep 17 00:00:00 2001 From: CI Date: Sat, 26 Sep 2026 09:12:30 +0100 Subject: [PATCH 6/7] fix(dynamic-load): only cancel Octopus Intelligent slots, never Predbat-led ones Predbat-led charging is a different mechanism: Predbat itself decides when the car charges, and its car_charging_slot sensor drives the charger. A second check here, for cars Predbat plans itself, ran before publish_car_plan(), so a cancelled slot switched that sensor off. The charger automation then stopped the charge the check was waiting to see, and every later slot in the run stayed cancelled. Detection now applies only to slots load_octopus_slots() built ("octopus": True). The second check is gone, and a single call before the rates decides every car, including one whose slots have gone this cycle, so a stale cancellation cannot outlive them. This changes behaviour from main: the old dynamic_load() also zeroed Predbat-planned slots on low load. It no longer does. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/fetch.py | 6 +-- apps/predbat/plan.py | 54 ++++++++++----------- apps/predbat/predbat.py | 1 - apps/predbat/tests/test_dynamic_load_car.py | 51 ++++++++++--------- docs/car-charging.md | 3 +- docs/customisation.md | 5 +- 6 files changed, 63 insertions(+), 57 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 0ffb50b2c..0b0800f3e 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1184,8 +1184,8 @@ def fetch_sensor_data(self, save=True): # Fetch sensor data for cars, e.g. car plan, car energy, car sessions etc. self.dispatch_timeline_pending = [] self.fetch_sensor_data_cars(save=save) - # Dynamic load: cancel the slots of a car that is in one but not charging - before the rates are - # built, so a cancelled Intelligent dispatch never gets its cheap rate + # Dynamic load: cancel the Octopus Intelligent slots of a car that is in one but not charging - + # before the rates are built, so a cancelled dispatch never gets its cheap rate dynamic_load_car_changed = self.dynamic_load_car_check(save=save) if "rates_export_octopus_url" in self.args: @@ -1314,8 +1314,6 @@ def fetch_sensor_data(self, save=True): # Work out car plan? self.fetch_sensor_data_car_planning() - # Dynamic load for the cars Predbat plans itself, whose slots only exist now - dynamic_load_car_changed |= self.dynamic_load_car_check(save=save, late=True) # Publish the car plan self.publish_car_plan() diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 04f843fad..1b35876a3 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -365,27 +365,36 @@ def dynamic_load_car_in_slot(self, car_n, minute): return True return False + def dynamic_load_car_is_octopus(self, car_n): + """ + Whether car_n's slots were built by Octopus Intelligent charging (load_octopus_slots() marks them). + + Only those are ever cancelled. Predbat-led charging is a different mechanism: Predbat itself + decides when the car charges and its car_charging_slot sensor drives the charger, so cancelling + a slot would stop the very charge the check is waiting to see. + """ + slots = self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else [] + return any(slot.get("octopus", False) for slot in slots) + def dynamic_load_car_trusted_by_default(self, car_n): """ Whether car_n's slots are trusted while there is no evidence about them - outside a slot, or before the car has been seen either way inside one. - Everything is trusted unless octopus_intelligent_trust_slots is Off, which reverses that for a car - carrying Octopus Intelligent dispatch slots: they are assumed not to happen until the car is seen - charging in one. Slots Predbat plans itself are always trusted by default. + Trusted unless octopus_intelligent_trust_slots is Off, which reverses that for an Octopus + Intelligent car: its slots are assumed not to happen until the car is seen charging in one. """ - if self.octopus_intelligent_trust_slots: - return True - slots = self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else [] - return not any(slot.get("octopus", False) for slot in slots) + return self.octopus_intelligent_trust_slots or not self.dynamic_load_car_is_octopus(car_n) def dynamic_load_car_active(self, car_n): """ - Whether car-charging detection applies to car_n: dynamic load is on, or the car's Octopus - Intelligent slots are untrusted by default - which means nothing without the detection that + Whether car-charging detection applies to car_n: only an Octopus Intelligent car, and only with + dynamic load on or its slots untrusted by default - which means nothing without the detection that trusts them again, so it works whether dynamic load is on or not. """ - return self.metric_dynamic_load_adjust or not self.dynamic_load_car_trusted_by_default(car_n) + if not self.dynamic_load_car_is_octopus(car_n): + return False + return self.metric_dynamic_load_adjust or not self.octopus_intelligent_trust_slots def dynamic_load_car_target(self, car_n, minute, now, record=True): """ @@ -455,16 +464,16 @@ def dynamic_load_car_check_config(self): self.dynamic_load_car_warned = cars return cars - def dynamic_load_car_check(self, save=True, late=False): + def dynamic_load_car_check(self, save=True): """ - Cancel the slots of a car that is inside one of its charging slots but not charging - or, with - octopus_intelligent_trust_slots Off, of an Octopus Intelligent car until it is seen charging. + Cancel the slots of an Octopus Intelligent car that is inside one of its charging slots but not + charging - or, with octopus_intelligent_trust_slots Off, until it is seen charging in one. Slots + Predbat plans itself are never cancelled (see dynamic_load_car_is_octopus()). - Runs in fetch_sensor_data() before the rates are built, so that a cancelled Octopus Intelligent - dispatch never gets its cheap rate (rate_add_io_slots() and dynamic_load_car_strip_feed_rates() - consult dynamic_load_car_cancelled). Cars that Predbat plans itself only get their slots after the - rates, from the low-rate scan, so a second call with late=True decides those once they exist - - they have no dispatch discount to withhold. + Runs in fetch_sensor_data() after the Octopus slots are built and before the rates, so that a + cancelled dispatch never gets its cheap rate (rate_add_io_slots() and + dynamic_load_car_strip_feed_rates() consult dynamic_load_car_cancelled). Every car is decided, + including one whose slots have gone, so a stale cancellation cannot outlive them. A cancelled car has every slot ending after now set to 0 kWh, which releases "Hold for car" and the predicted car load; the kWh it had is kept in the slot's kwh_cancelled so the plan can still show @@ -477,16 +486,7 @@ def dynamic_load_car_check(self, save=True, late=False): Returns True when a car's cancellation changed, so the plan is recomputed. """ changed = False - if not late: - self.dynamic_load_car_decided = set() - for car_n in range(self.num_cars): - if car_n in self.dynamic_load_car_decided: - continue - if not late and not (car_n < len(self.car_charging_slots) and self.car_charging_slots[car_n]): - continue - self.dynamic_load_car_decided.add(car_n) - cancelled = self.dynamic_load_car_target(car_n, self.minutes_now, self.now_utc_real, record=save) if save: was_cancelled = self.dynamic_load_car_cancelled.get(car_n, False) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index d98c0c888..60a0ce7b8 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -547,7 +547,6 @@ def reset(self): self.load_last_status = "baseline" self.dynamic_load_car_since = {} self.dynamic_load_car_cancelled = {} - self.dynamic_load_car_decided = set() self.dynamic_load_car_warned = [] self.dynamic_load_car_warned_iog_off = False self.dynamic_load_car_sensors = {} diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py index c8fd5a59b..6dab54ee9 100644 --- a/apps/predbat/tests/test_dynamic_load_car.py +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -17,7 +17,8 @@ re-derived every cycle and ends as soon as the car leaves its slots or starts charging again. Evidence is car_charging_now when it is a real entity (2 minute grace), otherwise the low-load test -when the car is inside the CT clamp (10 minute grace), otherwise nothing. +when the car is inside the CT clamp (10 minute grace), otherwise nothing. Only slots Octopus +Intelligent charging built are affected - Predbat-led charging is never cancelled. """ import copy from datetime import timedelta @@ -43,7 +44,6 @@ "update_pending", "dynamic_load_car_since", "dynamic_load_car_cancelled", - "dynamic_load_car_decided", "dynamic_load_car_sensors", "dynamic_load_car_warned", "dynamic_load_car_warned_iog_off", @@ -103,7 +103,6 @@ def _reset(my_predbat): my_predbat.load_last_period = 3.0 my_predbat.dynamic_load_car_since = {} my_predbat.dynamic_load_car_cancelled = {} - my_predbat.dynamic_load_car_decided = set() my_predbat.update_pending = False my_predbat.octopus_intelligent_trust_slots = True @@ -218,7 +217,7 @@ def _run(my_predbat): _sensor(my_predbat, None) my_predbat.car_energy_reported_load = True my_predbat.load_last_period = 0.5 - early_slots = [{"start": 0, "end": 30, "kwh": 3.5}] + early_slots = [{"start": 0, "end": 30, "kwh": 3.5, "octopus": True}] _cycle(my_predbat, 0, slots=early_slots) _cycle(my_predbat, 5, slots=early_slots) failed |= _check("t6b no clock before 00:05", my_predbat.dynamic_load_car_since.get(0) is None, "since {}".format(my_predbat.dynamic_load_car_since.get(0))) @@ -269,22 +268,31 @@ def _run(my_predbat): failed |= _check("t10 not applied outside the slot", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) failed |= _check("t10 saved state untouched", my_predbat.dynamic_load_car_cancelled.get(0, False), "cancelled {}".format(my_predbat.dynamic_load_car_cancelled)) - # Cars planned by Predbat itself get their slots after the rates are built: the late call - # decides the cars the early call could not - print("Test 11: the late call decides cars that had no slots at the early call") + # Predbat-led charging is a different mechanism: Predbat itself decides when the car charges, and + # its slot sensor drives the charger - cancelling it would stop the very charge it is waiting to + # see. Only slots Octopus Intelligent charging built are ever cancelled. + print("Test 11: a car Predbat plans itself is never cancelled, by the sensor or the load") _reset(my_predbat) _sensor(my_predbat, "off") - _at(my_predbat, 840) - my_predbat.car_charging_slots = [[]] - my_predbat.dynamic_load_car_check() - my_predbat.car_charging_slots = [_slots()] - my_predbat.dynamic_load_car_check(late=True) - _at(my_predbat, 845) - my_predbat.car_charging_slots = [[]] - my_predbat.dynamic_load_car_check() - my_predbat.car_charging_slots = [_slots()] - changed = my_predbat.dynamic_load_car_check(late=True) - failed |= _check("t11 late cancel", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + own = [{"start": 840, "end": 870, "kwh": 3.5}, {"start": 900, "end": 930, "kwh": 3.5}] + _cycle(my_predbat, 840, slots=own) + changed = _cycle(my_predbat, 850, slots=own) + failed |= _check("t11 sensor", (not changed) and _kwh(my_predbat) == [3.5, 3.5] and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} kwh {}".format(changed, _kwh(my_predbat))) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_last_period = 0.5 + _cycle(my_predbat, 840, slots=own) + _cycle(my_predbat, 855, slots=own) + failed |= _check("t11 load", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) + + # Every car is decided in the one call, including one whose slots have gone this cycle + print("Test 11a: a cancelled car with no slots this cycle is cleared") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + _cycle(my_predbat, 845) + changed = _cycle(my_predbat, 850, slots=[]) + failed |= _check("t11a cleared", changed and not my_predbat.dynamic_load_car_cancelled.get(0, False), "changed {} cancelled {}".format(changed, my_predbat.dynamic_load_car_cancelled)) # car_charging_slots shorter than num_cars must not raise out of the fetch (#5229 review) print("Test 11b: fewer car slot lists than cars") @@ -295,10 +303,9 @@ def _run(my_predbat): my_predbat.dynamic_load_car_cancelled = {1: True} my_predbat.car_charging_slots = [_slots()] try: - my_predbat.dynamic_load_car_check(save=False, late=True) + my_predbat.dynamic_load_car_check(save=False) my_predbat.dynamic_load_car_cancelled = {1: True} my_predbat.dynamic_load_car_check() - my_predbat.dynamic_load_car_check(late=True) except IndexError as exc: failed |= _check("t11b no IndexError", False, str(exc)) @@ -462,7 +469,7 @@ def _run_poll(my_predbat): print("Test 18: the poll agrees with the check about a slot ending between 5 minute steps") _reset(my_predbat) _sensor(my_predbat, "off") - odd_slots = [{"start": 840, "end": 872, "kwh": 3.5}] + odd_slots = [{"start": 840, "end": 872, "kwh": 3.5, "octopus": True}] _cycle(my_predbat, 840, slots=odd_slots) _cycle(my_predbat, 845, slots=odd_slots) my_predbat.update_pending = False @@ -657,7 +664,7 @@ def _run_kwh_cancelled(my_predbat): print("Test 26: a cancelled slot records its kWh in kwh_cancelled") _reset(my_predbat) _sensor(my_predbat, "off") - slots = [{"start": 840, "end": 870, "kwh": 3.5}, {"start": 900, "end": 930, "kwh": 0.0}] + slots = [{"start": 840, "end": 870, "kwh": 3.5, "octopus": True}, {"start": 900, "end": 930, "kwh": 0.0, "octopus": True}] _cycle(my_predbat, 840, slots=slots) _cycle(my_predbat, 845, slots=slots) failed |= _check("t26 recorded", _kwh(my_predbat) == [0, 0] and _cancelled(my_predbat) == [3.5, None], "kwh {} cancelled {}".format(_kwh(my_predbat), _cancelled(my_predbat))) diff --git a/docs/car-charging.md b/docs/car-charging.md index dec879dde..7be7217ab 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -627,7 +627,8 @@ e.g. you are using Intelligent Octopus or you use the car slots in Predbat to co - **input_number.predbat_car_charging_loss** gives the percentage amount of energy lost when charging the car (load in the home vs energy added to the battery). A good setting is 0.08 which is 8%. -- **switch.predbat_metric_dynamic_load_adjust** (default `false`) - When On, Predbat cancels a car's charging slots, including the cheap rate of an Octopus Intelligent dispatch, while the car is inside a slot but not actually charging, and restores them as soon as it starts charging again or the slot ends. +- **switch.predbat_metric_dynamic_load_adjust** (default `false`) - When On, Predbat cancels a car's Octopus Intelligent charging slots, including the dispatch's cheap rate, while the car is inside a slot but not actually charging, and restores them as soon as it starts charging again or the slot ends. +Slots Predbat plans itself (Predbat-led charging) are never cancelled. This releases the battery hold set by **switch.predbat_car_charging_from_battery**, and stops the house battery being planned around a dispatch Octopus may bill at the full rate. It uses **car_charging_now** when that is a real sensor (reacting within about 2 minutes), otherwise the house load if the car is inside the CT clamp (about 10 minutes). If turned Off then Predbat won't export during times the car is planned to charge even if the car is not charging. diff --git a/docs/customisation.md b/docs/customisation.md index a5d0e8d09..3c67aaf95 100644 --- a/docs/customisation.md +++ b/docs/customisation.md @@ -189,8 +189,9 @@ This setting will not impact the real calculated costs and is only used for plan **switch.predbat_metric_dynamic_load_adjust** (default False) is a toggle that when enabled allows Predbat to take into account your energy consumption within the last 5 minutes. If the load is above what your battery can deliver the plan is updated to predict this load will continue during the current slot, thus preventing forced export in the plan. If the load remains high for two checks in a row, this prediction is extended into the following slot too, so the plan stays up to date across the slot boundary. -If the current time is inside one of your car's charging slots but the car is not charging, Predbat cancels that slot and every later one, so the battery is no longer held for the car and the plan can include potential export again. -For an Octopus Intelligent dispatch the slot's cheap rate is withdrawn too, as Octopus may bill a dispatch the car does not use at the full rate. +If your car is charged by Octopus Intelligent and the current time is inside one of its charging slots but the car is not charging, Predbat cancels that slot and every later one, so the battery is no longer held for the car and the plan can include potential export again. +The dispatch's cheap rate is withdrawn too, as Octopus may bill a dispatch the car does not use at the full rate. +Slots Predbat plans itself (Predbat-led charging) are never cancelled, as Predbat is the one deciding when the car charges. Whether the car is charging is taken from **car_charging_now** when it is set to a real Home Assistant entity, after 2 minutes of it reporting not charging. Without that sensor, if your car is inside the CT clamp (**switch.predbat_car_energy_reported_load** On), the house load is used instead, after 10 minutes of load too low for a car to be charging. The slots come back as soon as the car starts charging again or the current slot ends. From accbf992d3a74a89db8b8eefad59d70373fca603 Mon Sep 17 00:00:00 2001 From: CI Date: Sat, 26 Sep 2026 09:46:19 +0100 Subject: [PATCH 7/7] fix(dynamic-load): address #5229 review round 3 - compare.py runs (save=False): the rates read the saved cancellation while the slots followed a decision made fresh in that run. So a car that had started or stopped charging since the last live cycle was priced one way and planned the other. The check now records this run's decision in dynamic_load_car_effective, and rate_add_io_slots() and the feed strip read that instead. - An install with no load history (load_forecast only, where load_last_period is hard-coded to 0) counted as low load, so with car_energy_reported_load on every dispatch was cancelled 10 minutes in. No load data is now no evidence. - An unknown/unavailable reading left the grace timer running, so off -> unavailable -> off cancelled without a fresh grace. No evidence now restarts it. - A cancelled slot kept its cost. The cost is now zeroed, and publish_car_plan() includes kwh_cancelled in the planned attribute, so the entity attributes say why kWh is 0. - The "will never be trusted" warning now lists only cars with an octopus_intelligent_slot of their own, not every car without a sensor. - The feed strip re-strips the same minutes every cycle. It now logs only when the number stripped changes. Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/octopus.py | 10 ++- apps/predbat/output.py | 2 + apps/predbat/plan.py | 24 +++++- apps/predbat/predbat.py | 2 + apps/predbat/tests/test_dynamic_load_car.py | 87 ++++++++++++++++++++- 5 files changed, 116 insertions(+), 9 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index e1ad2e0a4..08257c8e3 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3604,7 +3604,7 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # Dynamic load has seen this car in its slot but not charging: none of its dispatches from now # on get the cheap rate (see dynamic_load_car_check()). Elapsed minutes keep theirs - they record # what the tariff charged, and today's cost is built from them. - car_cancelled = self.dynamic_load_car_cancelled.get(car_n, False) + car_cancelled = self.dynamic_load_car_effective.get(car_n, False) if octopus_slots: # Add in IO slots @@ -3710,8 +3710,9 @@ def dynamic_load_car_strip_feed_rates(self, rates): cancelled car's dispatch and not covered by a dispatch of any car still trusted - the rates are shared by every car. """ - cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_cancelled.get(car_n, False)] + cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_effective.get(car_n, False)] if not cancelled_cars or not self.io_adjusted: + self.dynamic_load_car_stripped = 0 return rates # Rounded out to whole 30 minute rate periods: the feed marks and prices whole periods, and @@ -3741,8 +3742,11 @@ def dynamic_load_car_strip_feed_rates(self, rates): rates[minute] = self.rate_max_base del self.io_adjusted[minute] stripped += 1 - if stripped: + # The feed is re-read every cycle, so the same minutes are stripped again each time - log only + # when that changes, not every 5 minutes for the length of a cancellation + if stripped and stripped != self.dynamic_load_car_stripped: self.log("Dynamic load: removed the Intelligent dispatch rate from {} minutes of cars {} which are not charging".format(stripped, cancelled_cars)) + self.dynamic_load_car_stripped = stripped return rates def fetch_octopus_rates(self, entity_id, adjust_key=None): diff --git a/apps/predbat/output.py b/apps/predbat/output.py index df2f52687..c4ddec308 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -193,6 +193,8 @@ def publish_car_plan(self): show["kwh"] = kwh show["average"] = average show["cost"] = cost + if window.get("kwh_cancelled"): + show["kwh_cancelled"] = dp2(window["kwh_cancelled"]) total_cost += cost total_kwh += kwh plan.append(show) diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 1b35876a3..7f2ba3928 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -350,7 +350,9 @@ def dynamic_load_car_evidence(self, car_n): charging = bool(raw) return (not charging), DYNAMIC_LOAD_CAR_SENSOR_MINUTES if self.car_energy_reported_load: - if self.minutes_now <= 5: + # No load history (e.g. a load_forecast-only install, where load_last_period is a hard-coded + # 0) is not low load - it is no reading at all + if self.minutes_now <= 5 or not self.load_minutes: return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES return self.dynamic_load_classify() == "low", DYNAMIC_LOAD_CAR_LOAD_MINUTES return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES @@ -426,6 +428,10 @@ def dynamic_load_car_target(self, car_n, minute, now, record=True): since = self.dynamic_load_car_since.setdefault(car_n, now) if record else self.dynamic_load_car_since.get(car_n) if since is not None and (now - since).total_seconds() >= grace_minutes * 60: return True + elif record: + # No evidence: the car may have been charging through it, so a "not charging" reading after + # it starts a fresh grace period rather than counting the unknown stretch towards the old one + self.dynamic_load_car_since.pop(car_n, None) return cancelled def dynamic_load_car_refresh_sensors(self): @@ -457,7 +463,13 @@ def dynamic_load_car_check_config(self): cars = [] if not self.octopus_intelligent_trust_slots and self.octopus_intelligent_charging and not self.car_energy_reported_load: - cars = [car_n for car_n in range(self.num_cars) if car_n not in self.dynamic_load_car_sensors] + # Only cars on Octopus Intelligent (with a slot sensor of their own, as fetch_sensor_data_cars() + # reads them) have Intelligent slots to distrust + iog_entities = self.get_arg("octopus_intelligent_slot", indirect=False) + if iog_entities and not isinstance(iog_entities, list): + iog_entities = [iog_entities] + iog_entities = iog_entities or [] + cars = [car_n for car_n in range(self.num_cars) if car_n < len(iog_entities) and iog_entities[car_n] and car_n not in self.dynamic_load_car_sensors] if cars != self.dynamic_load_car_warned: if cars: self.log("Warn: octopus_intelligent_trust_slots is Off but car(s) {} have no car_charging_now sensor and are outside the CT clamp, so their Intelligent slots will never be trusted".format(cars)) @@ -486,6 +498,9 @@ def dynamic_load_car_check(self, save=True): Returns True when a car's cancellation changed, so the plan is recomputed. """ changed = False + # This run's decision, which the rates read - in a compare.py run (save False) it can differ from + # the saved state, and the rates must follow the same decision as the slots + self.dynamic_load_car_effective = {} for car_n in range(self.num_cars): cancelled = self.dynamic_load_car_target(car_n, self.minutes_now, self.now_utc_real, record=save) if save: @@ -500,6 +515,7 @@ def dynamic_load_car_check(self, save=True): reason = "slots resumed" self.log("Dynamic load: car {} {}".format(car_n, reason)) self.dynamic_load_car_cancelled[car_n] = cancelled + self.dynamic_load_car_effective[car_n] = cancelled if cancelled and car_n < len(self.car_charging_slots): for slot in self.car_charging_slots[car_n]: @@ -507,6 +523,10 @@ def dynamic_load_car_check(self, save=True): if slot.get("kwh", 0) > 0: slot["kwh_cancelled"] = slot["kwh"] slot["kwh"] = 0 + # No cost for energy the plan no longer counts, so the published car plan stays + # self-consistent + if "cost" in slot: + slot["cost"] = 0 return changed def dynamic_load_car_poll(self, now=None): diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 60a0ce7b8..d237c8599 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -550,6 +550,8 @@ def reset(self): self.dynamic_load_car_warned = [] self.dynamic_load_car_warned_iog_off = False self.dynamic_load_car_sensors = {} + self.dynamic_load_car_effective = {} + self.dynamic_load_car_stripped = 0 self.battery_capacity_nominal = False self.battery_scaling_auto = False self.releases = {} diff --git a/apps/predbat/tests/test_dynamic_load_car.py b/apps/predbat/tests/test_dynamic_load_car.py index 6dab54ee9..4d7070de4 100644 --- a/apps/predbat/tests/test_dynamic_load_car.py +++ b/apps/predbat/tests/test_dynamic_load_car.py @@ -47,6 +47,9 @@ "dynamic_load_car_sensors", "dynamic_load_car_warned", "dynamic_load_car_warned_iog_off", + "dynamic_load_car_effective", + "dynamic_load_car_stripped", + "load_minutes", "octopus_intelligent_trust_slots", ) @@ -103,8 +106,10 @@ def _reset(my_predbat): my_predbat.load_last_period = 3.0 my_predbat.dynamic_load_car_since = {} my_predbat.dynamic_load_car_cancelled = {} + my_predbat.dynamic_load_car_effective = {} my_predbat.update_pending = False my_predbat.octopus_intelligent_trust_slots = True + my_predbat.load_minutes = {0: 10.0, 5: 9.9} def _kwh(my_predbat): @@ -176,6 +181,20 @@ def _run(my_predbat): changed = _cycle(my_predbat, 860) failed |= _check("t4 unknown keeps cancellation", (not changed) and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + # An unknown stretch is no evidence either way: the car may have been charging through it, so a + # "not charging" reading after it starts a fresh grace period rather than resuming the old one + print("Test 4c: an unavailable reading restarts the grace clock") + _reset(my_predbat) + _sensor(my_predbat, "off") + _cycle(my_predbat, 840) + _sensor(my_predbat, "unavailable") + _cycle(my_predbat, 841) + _sensor(my_predbat, "off") + changed = _cycle(my_predbat, 842, 30) + failed |= _check("t4c not cancelled on the old sighting", (not changed) and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + changed = _cycle(my_predbat, 844, 30) + failed |= _check("t4c cancelled after a fresh grace", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + # A static literal is not a sensor, and without the CT clamp there is no load evidence either # A configured entity HA does not have (deleted, renamed, integration reloading) resolves to the # read's default - that must be no evidence, not a "not charging" reading (#5229 review) @@ -212,6 +231,18 @@ def _run(my_predbat): changed = _cycle(my_predbat, 855) failed |= _check("t6 load back up resumes", changed and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat))) + # A load_forecast-only install has no load_today history and a hard-coded load_last_period of 0, + # which is not evidence of anything (#5229 review) + print("Test 6c: no load history is not low-load evidence") + _reset(my_predbat) + _sensor(my_predbat, None) + my_predbat.car_energy_reported_load = True + my_predbat.load_minutes = {} + my_predbat.load_last_period = 0 + _cycle(my_predbat, 840) + _cycle(my_predbat, 855) + failed |= _check("t6c no load data", _kwh(my_predbat) == [3.5, 3.5] and my_predbat.dynamic_load_car_since.get(0) is None, "kwh {}".format(_kwh(my_predbat))) + print("Test 6b: the load test is not used just after midnight, when load_today resets") _reset(my_predbat) _sensor(my_predbat, None) @@ -267,6 +298,13 @@ def _run(my_predbat): _cycle(my_predbat, 875, save=False) failed |= _check("t10 not applied outside the slot", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat))) failed |= _check("t10 saved state untouched", my_predbat.dynamic_load_car_cancelled.get(0, False), "cancelled {}".format(my_predbat.dynamic_load_car_cancelled)) + # ...and the rates in that comparison run follow the same decision as its slots (#5229 review) + _sensor(my_predbat, "on") + _cycle(my_predbat, 851, save=False) + failed |= _check("t10 comparison rates follow the run's own decision", my_predbat.dynamic_load_car_effective.get(0) is False, "effective {}".format(my_predbat.dynamic_load_car_effective)) + _sensor(my_predbat, "off") + _cycle(my_predbat, 852, save=False) + failed |= _check("t10 comparison rates cancelled with its slots", my_predbat.dynamic_load_car_effective.get(0) is True and _kwh(my_predbat) == [0, 0], "effective {} kwh {}".format(my_predbat.dynamic_load_car_effective, _kwh(my_predbat))) # Predbat-led charging is a different mechanism: Predbat itself decides when the car charges, and # its slot sensor drives the charger - cancelling it would stop the very charge it is waiting to @@ -349,13 +387,13 @@ def _run_rates(my_predbat): my_predbat.args["octopus_slot_max"] = 48 my_predbat.rate_min_base = 4.0 my_predbat.rate_max_base = 30.0 - my_predbat.dynamic_load_car_cancelled = {0: True} + my_predbat.dynamic_load_car_effective = {0: True} rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 810, 900)]) failed |= _check("t12 past minute keeps its discount", rates[820] == 4.0, "rate {}".format(rates[820])) failed |= _check("t12 future minute not discounted", rates[840] == 10.0 and rates[899] == 10.0, "rates {} {}".format(rates[840], rates[899])) - my_predbat.dynamic_load_car_cancelled = {} + my_predbat.dynamic_load_car_effective = {} rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)} rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 810, 900)]) failed |= _check("t12 not cancelled is discounted", rates[840] == 4.0, "rate {}".format(rates[840])) @@ -365,7 +403,7 @@ def _run_rates(my_predbat): _at(my_predbat, 840) my_predbat.num_cars = 2 my_predbat.rate_max_base = 30.0 - my_predbat.dynamic_load_car_cancelled = {0: True} + my_predbat.dynamic_load_car_effective = {0: True} my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]] rates = {minute: 25.0 for minute in range(0, 2 * 24 * 60)} my_predbat.io_adjusted = {} @@ -416,7 +454,22 @@ def _run_rates(my_predbat): my_predbat.num_cars = 2 my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]] - my_predbat.dynamic_load_car_cancelled = {} + print("Test 13d: the feed strip logs when what it strips changes, not every cycle") + my_predbat.dynamic_load_car_effective = {0: True} + my_predbat.dynamic_load_car_stripped = 0 + logged = [] + saved_log = my_predbat.log + my_predbat.log = lambda message, *args, **kwargs: logged.append(message) + try: + for _ in range(3): + my_predbat.io_adjusted = {minute: True for minute in range(840, 870)} + my_predbat.dynamic_load_car_strip_feed_rates({minute: 7.0 for minute in range(0, 2 * 24 * 60)}) + finally: + my_predbat.log = saved_log + strip_logs = [message for message in logged if "removed the Intelligent dispatch rate" in message] + failed |= _check("t13d logged once", len(strip_logs) == 1, "logged {}".format(strip_logs)) + + my_predbat.dynamic_load_car_effective = {} rates[850] = 7.0 my_predbat.io_adjusted[850] = True rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) @@ -612,6 +665,9 @@ def _run_untrusted(my_predbat): my_predbat.octopus_intelligent_trust_slots = False my_predbat.car_energy_reported_load = False my_predbat.dynamic_load_car_warned = [] + had_slot_sensor_27 = "octopus_intelligent_slot" in my_predbat.args + saved_slot_sensor_27 = my_predbat.args.get("octopus_intelligent_slot") + my_predbat.args["octopus_intelligent_slot"] = "binary_sensor.octopus_intelligent_slot_test" cars = my_predbat.dynamic_load_car_check_config() failed |= _check("t27 no evidence warned", cars == [0] and my_predbat.dynamic_load_car_warned == [0], "cars {}".format(cars)) my_predbat.car_energy_reported_load = True @@ -619,7 +675,17 @@ def _run_untrusted(my_predbat): my_predbat.car_energy_reported_load = False _sensor(my_predbat, "off") failed |= _check("t27 sensor is evidence", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned)) + # A second car with no Intelligent slot sensor of its own has no Intelligent slots to distrust + print("Test 27c: only cars on Octopus Intelligent are warned about") _sensor(my_predbat, None) + my_predbat.num_cars = 2 + my_predbat.dynamic_load_car_refresh_sensors() + failed |= _check("t27c mixed install", my_predbat.dynamic_load_car_check_config() == [0], "cars {}".format(my_predbat.dynamic_load_car_warned)) + my_predbat.num_cars = 1 + if had_slot_sensor_27: + my_predbat.args["octopus_intelligent_slot"] = saved_slot_sensor_27 + else: + my_predbat.args.pop("octopus_intelligent_slot", None) my_predbat.octopus_intelligent_trust_slots = True failed |= _check("t27 trust On never warns", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned)) @@ -672,6 +738,19 @@ def _run_kwh_cancelled(my_predbat): _sensor(my_predbat, "on") _cycle(my_predbat, 850, slots=slots) failed |= _check("t26 gone once resumed", _cancelled(my_predbat) == [None, None], "cancelled {}".format(_cancelled(my_predbat))) + + # The published car plan stays self-consistent for a cancelled slot: no cost for energy the plan no + # longer counts, and the cancelled kWh alongside so the attributes say why kWh is 0 (#5229 review) + print("Test 26b: a cancelled slot is published with no cost and its kwh_cancelled") + _sensor(my_predbat, "off") + priced = [{"start": 840, "end": 870, "kwh": 3.5, "average": 7.0, "cost": 24.5, "soc": 10.0, "octopus": True}] + _cycle(my_predbat, 840, slots=priced) + _cycle(my_predbat, 845, slots=priced) + failed |= _check("t26b cost zeroed", my_predbat.car_charging_slots[0][0].get("cost") == 0, "slot {}".format(my_predbat.car_charging_slots[0][0])) + my_predbat.publish_car_plan() + planned = my_predbat.get_state_wrapper("binary_sensor." + my_predbat.prefix + "_car_charging_slot", attribute="planned") or [] + first = planned[0] if planned else {} + failed |= _check("t26b published", first.get("kwh") == 0 and first.get("cost") == 0 and first.get("kwh_cancelled") == 3.5, "planned {}".format(planned)) return failed