From 9625aa21e2e92fc15522968d3ddf6a432453b6cc Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Fri, 14 Aug 2026 21:13:20 +0100 Subject: [PATCH 01/11] feat(octopus): log a per-car IOG dispatch timeline diagnostic (#4516 Stage 1) Issue #4516: a future daytime IOG dispatch slot is still Octopus's own provisional/revisable plan, but Predbat currently treats it the same as a confirmed one. Before building any gating/replan logic on a specific confirmation signal, first observe real dispatch lifecycles - two signals investigated for this (intelligent_charge_cap_hours, then the intelligent_dispatching binary_sensor's default mode) both looked promising and both turned out unreliable on inspection. Adds build_dispatch_timeline() (octopus.py): a small render helper producing a fixed-width, one-character-per-30-min-block status string ('.' nothing, 'P' planned, 'S' started, 'C' completed) across a -4h..+24h window around now. Logged once per car per 30-minute boundary from fetch_sensor_data_cars() - purely observational, not merged into octopus_slots or used for any rate/plan decision. Stacking consecutive lines in a monospace log viewer reveals dispatch lifecycle as diagonal stripes: a specific dispatch drifts one column per line as now advances, so a rescinded slot shows as a stripe that stops before reaching the 'now' column. Also reads started_dispatches/startedDispatches from the octopus_intelligent_slot entity for the first time - present on the entity (confirmed against the actual Octopus Energy HA integration source) but not previously read anywhere in Predbat. 8 new unit tests for the render helper (empty/planned/started/completed rendering, confirmed-status priority ordering, out-of-window slots, multi-block spans). Sanity-checked against a real captured debug.yaml's octopus_slots data - handles real-world irregularities (negative charge_in_kwh, non-standard location values) without crashing. Stage 2 (the actual confirmed-slot gating + immediate-replan trigger) is deferred until dispatch timelines have been observed for a while. --- apps/predbat/fetch.py | 13 ++ apps/predbat/octopus.py | 39 +++++ apps/predbat/tests/test_dispatch_timeline.py | 143 +++++++++++++++++++ apps/predbat/unit_test.py | 2 + 4 files changed, 197 insertions(+) create mode 100644 apps/predbat/tests/test_dispatch_timeline.py diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 7cbf78a0a..09345a532 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1194,6 +1194,7 @@ def fetch_sensor_data_cars(self): completed = [] planned = [] + started = [] if entity_id and "octopus_intelligent_slot_action_config" in self.args: config_entry = self.get_arg("octopus_intelligent_slot_action_config", None, indirect=False) @@ -1207,10 +1208,22 @@ def fetch_sensor_data_cars(self): try: completed = self.get_state_wrapper(entity_id=entity_id, attribute="completed_dispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="completedDispatches") planned = self.get_state_wrapper(entity_id=entity_id, attribute="planned_dispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="plannedDispatches") + # Not merged into octopus_slots or used for any rate/plan decision yet - read + # only for the #4516 Stage 1 diagnostic timeline log below, to observe whether + # this is a trustworthy earlier-than-completed confirmation signal before + # building any gating logic on it (Stage 2, deferred). + started = self.get_state_wrapper(entity_id=entity_id, attribute="started_dispatches") or self.get_state_wrapper(entity_id=entity_id, attribute="startedDispatches") except (ValueError, TypeError): self.log("Warn: Unable to get data from {} for car {} - octopus_intelligent_slot may not be set correctly in apps.yaml".format(entity_id, car_n)) self.record_status(message="Error: octopus_intelligent_slot not set correctly in apps.yaml for car {}".format(car_n), had_errors=True) + # #4516 Stage 1: diagnostic dispatch-timeline log, once per 30-minute boundary so + # log volume stays sane (dispatch decisions are 30-min-granular anyway). Purely + # observational - see build_dispatch_timeline()'s docstring. + if self.minutes_now % 30 == 0: + timeline = self.build_dispatch_timeline(car_n, completed, started, planned) + self.log("Octopus: Dispatch timeline car {} @ {} [-4h..+24h]: {}".format(car_n, self.time_abs_str(self.minutes_now), timeline)) + # Completed and planned slots - merge from all cars if completed: self.octopus_slots[car_n] += completed diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 2e4951711..399130563 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2544,6 +2544,45 @@ def decode_octopus_slot(self, car_n, slot, raw=False): return start_minutes, end_minutes, kwh, source, location + def build_dispatch_timeline(self, car_n, completed, started, planned, window_before_hours=4, window_after_hours=24, step=30): + """ + Build a fixed-width, one-character-per-block dispatch status string for the diagnostic + timeline log (#4516 Stage 1 - not yet used for any rate/plan decision, purely observational). + + Each character covers `step` minutes, offset from now, spanning + [-window_before_hours, +window_after_hours). '.' = nothing known, 'P' = planned + (provisional), 'S' = started, 'C' = completed. Where lists disagree on the same block, the + most-confirmed status wins (completed > started > planned) - reflects Octopus's own view + having moved on, not a genuine simultaneous claim. + + Stacking consecutive lines (one per 30-minute boundary) in a monospace log viewer reveals + dispatch lifecycle as diagonal stripes: a specific dispatch drifts one column per line as + `now` advances, so a rescinded slot shows as a stripe that stops before reaching the `now` + column, while a genuinely-delivered one runs through into 'C'. + """ + total_before = window_before_hours * 60 + total_after = window_after_hours * 60 + num_blocks = (total_before + total_after) // step + status = ["."] * num_blocks + + def mark(slots, char): + for slot in slots or []: + start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True) + if start_minutes == end_minutes: + continue + start_offset = start_minutes - self.minutes_now + end_offset = end_minutes - self.minutes_now + block_start = max(0, (start_offset + total_before) // step) + block_end = min(num_blocks, -(-(end_offset + total_before) // step)) # ceil division + for block in range(int(block_start), int(block_end)): + status[block] = char + + mark(planned, "P") + mark(started, "S") + mark(completed, "C") + + return "".join(status) + def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_full): """ Turn octopus slots into charging plan diff --git a/apps/predbat/tests/test_dispatch_timeline.py b/apps/predbat/tests/test_dispatch_timeline.py new file mode 100644 index 000000000..b278abc79 --- /dev/null +++ b/apps/predbat/tests/test_dispatch_timeline.py @@ -0,0 +1,143 @@ +# ----------------------------------------------------------------------------- +# 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 + +""" +Unit tests for Octopus.build_dispatch_timeline() - the #4516 Stage 1 diagnostic dispatch-timeline +render helper. Purely observational (not used for any rate/plan decision yet), so these tests only +check the rendered status string, not any side effect on rates or the plan. +""" + +from datetime import datetime, timedelta + +import pytz + +UTC = pytz.UTC +TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" + + +def _slot(start, end): + return {"start": start.strftime(TIME_FORMAT), "end": end.strftime(TIME_FORMAT), "source": "smart-charge", "location": "AT_HOME"} + + +def run_dispatch_timeline_tests(my_predbat): + """ + Test for build_dispatch_timeline() - the #4516 Stage 1 diagnostic dispatch-timeline render. + """ + failed = False + print("**** Running dispatch_timeline tests ****") + + # Save state that will be mutated + saved_now_utc = my_predbat.now_utc + saved_midnight_utc = my_predbat.midnight_utc + saved_minutes_now = my_predbat.minutes_now + + try: + # Fixed, mutually-consistent midnight/now so this test doesn't depend on (or leak into) + # whatever the ambient fixture state happens to be elsewhere in a full suite run. + midnight_utc = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) + my_predbat.midnight_utc = midnight_utc + my_predbat.now_utc = midnight_utc + timedelta(hours=10) + my_predbat.minutes_now = 10 * 60 + + # Default window: -4h..+24h at 30-min step = 56 blocks. "now" (offset 0) sits at block 8 (4h * 2). + NUM_BLOCKS = 56 + NOW_BLOCK = 8 + + print("Test 1: nothing scheduled - all dots") + timeline = my_predbat.build_dispatch_timeline(0, [], [], []) + if timeline != "." * NUM_BLOCKS: + print(" ERROR: expected all dots, got {!r}".format(timeline)) + failed = True + + print("Test 2: a single future planned slot renders as P at the right offset") + slot_start = midnight_utc + timedelta(hours=12) # 2h from now (now=10:00) -> offset block NOW_BLOCK+4 + slot_end = slot_start + timedelta(minutes=30) + timeline = my_predbat.build_dispatch_timeline(0, [], [], [_slot(slot_start, slot_end)]) + expected = list("." * NUM_BLOCKS) + expected[NOW_BLOCK + 4] = "P" + expected = "".join(expected) + if timeline != expected: + print(" ERROR: expected {!r}, got {!r}".format(expected, timeline)) + failed = True + + print("Test 3: a currently-active started slot renders as S") + slot_start = midnight_utc + timedelta(hours=10) # now + slot_end = slot_start + timedelta(minutes=30) + timeline = my_predbat.build_dispatch_timeline(0, [], [_slot(slot_start, slot_end)], []) + expected = list("." * NUM_BLOCKS) + expected[NOW_BLOCK] = "S" + expected = "".join(expected) + if timeline != expected: + print(" ERROR: expected {!r}, got {!r}".format(expected, timeline)) + failed = True + + print("Test 4: a past completed slot renders as C, placed before the 'now' column") + slot_start = midnight_utc + timedelta(hours=9) # 1h before now -> offset block NOW_BLOCK-2 + slot_end = slot_start + timedelta(minutes=30) + timeline = my_predbat.build_dispatch_timeline(0, [_slot(slot_start, slot_end)], [], []) + expected = list("." * NUM_BLOCKS) + expected[NOW_BLOCK - 2] = "C" + expected = "".join(expected) + if timeline != expected: + print(" ERROR: expected {!r}, got {!r}".format(expected, timeline)) + failed = True + + print("Test 5: when completed, started and planned all cover the same block, completed wins") + slot_start = midnight_utc + timedelta(hours=12) + slot_end = slot_start + timedelta(minutes=30) + slot = _slot(slot_start, slot_end) + timeline = my_predbat.build_dispatch_timeline(0, [slot], [slot], [slot]) + expected = list("." * NUM_BLOCKS) + expected[NOW_BLOCK + 4] = "C" + expected = "".join(expected) + if timeline != expected: + print(" ERROR: expected completed to win over started/planned, got {!r}".format(timeline)) + failed = True + + print("Test 6: started wins over planned when completed is absent") + timeline = my_predbat.build_dispatch_timeline(0, [], [slot], [slot]) + expected = list("." * NUM_BLOCKS) + expected[NOW_BLOCK + 4] = "S" + expected = "".join(expected) + if timeline != expected: + print(" ERROR: expected started to win over planned, got {!r}".format(timeline)) + failed = True + + print("Test 7: a slot entirely outside the window has no effect and does not crash") + slot_start = midnight_utc + timedelta(days=3) # far beyond +24h + slot_end = slot_start + timedelta(minutes=30) + timeline = my_predbat.build_dispatch_timeline(0, [], [], [_slot(slot_start, slot_end)]) + if timeline != "." * NUM_BLOCKS: + print(" ERROR: expected all dots for an out-of-window slot, got {!r}".format(timeline)) + failed = True + + print("Test 8: a slot spanning multiple 30-min blocks marks every block it touches") + slot_start = midnight_utc + timedelta(hours=12) + slot_end = slot_start + timedelta(hours=1, minutes=30) # 3 blocks + timeline = my_predbat.build_dispatch_timeline(0, [], [], [_slot(slot_start, slot_end)]) + expected = list("." * NUM_BLOCKS) + expected[NOW_BLOCK + 4] = "P" + expected[NOW_BLOCK + 5] = "P" + expected[NOW_BLOCK + 6] = "P" + expected = "".join(expected) + if timeline != expected: + print(" ERROR: expected 3 consecutive P blocks, got {!r}".format(timeline)) + failed = True + finally: + my_predbat.now_utc = saved_now_utc + my_predbat.midnight_utc = saved_midnight_utc + my_predbat.minutes_now = saved_minutes_now + + if failed: + print("\n**** dispatch_timeline tests: FAILED ****") + else: + print("\n**** dispatch_timeline tests: PASSED ****") + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 918471906..3951cbcf7 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -174,6 +174,7 @@ from tests.test_annual_tariff import test_annual_tariff from tests.test_rate_add_io_slots import run_rate_add_io_slots_tests from tests.test_iog_charge_skew import run_iog_charge_skew_tests +from tests.test_dispatch_timeline import run_dispatch_timeline_tests from tests.test_battery_curve_keys import run_battery_curve_keys_tests from tests.test_balance_inverters import run_balance_inverters_tests from tests.test_octopus_download_rates import test_octopus_download_rates_wrapper @@ -421,6 +422,7 @@ def main(): ("multi_car_iog", run_multi_car_iog_tests, "Multi-car IOG tests", False), ("rate_add_io_slots", run_rate_add_io_slots_tests, "Rate add IO slots tests", False), ("iog_charge_skew", run_iog_charge_skew_tests, "IOG earlier-charge skew characterisation tests", False), + ("dispatch_timeline", run_dispatch_timeline_tests, "Dispatch timeline diagnostic tests (#4516 Stage 1)", False), ("rate_replicate", test_rate_replicate, "Rate replicate comprehensive tests (missing slots, IO, offsets, gas)", False), ("find_charge_window", test_find_charge_window, "Find charge window gap handling tests", False), ("find_charge_rate", test_find_charge_rate, "Find charge rate tests", False), From a35d961846dde1f9d43274a9eec59cc473457b2c Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 15 Aug 2026 09:15:20 +0100 Subject: [PATCH 02/11] feat(octopus): add trust_future_dynamic_iog_slots to exclude provisional IOG slots from battery planning (#4516) A future daytime Octopus Intelligent dispatch slot (outside the fixed 23:30-05:30 off-peak window) is still Octopus's own provisional plan - it can be moved or withdrawn before it happens. Predbat previously treated it exactly the same as a confirmed/completed one when setting the house import rate, so it could commit the battery to a decision (e.g. an early force-export) in anticipation of a cheap recharge that never occurs. Adds a new expert-mode switch, trust_future_dynamic_iog_slots (default Off): with it off, rate_add_io_slots() only trusts a dispatch slot as cheap for house battery planning if it's inside the fixed IOG window (minute_in_iog_fixed_window(), reintroduced from OCTOPUS_NIGHT_RATE_WINDOWS - already used elsewhere for the same tariff's day/night rate selection, not new "magic" numbers). Car charging forecasting is unaffected - octopus_slots still flows into load_octopus_slots()/car_charging_slots exactly as before; only whether a slot is trusted as a cheap rate for the *battery* plan changes. Also adds exclude_dynamic_io_slots(), a separate post-pass called once after the per-car rate_add_io_slots() loop: rate_add_io_slots() only stops itself adding a new dynamic-slot discount, but for a genuine Intelligent tariff fetch_octopus_rates() can already receive the dispatch-discounted rate directly from the rate feed (marked via self.io_adjusted), independently of the octopus_slots dispatch-list mechanism. Without this second pass the switch would be silently defeated for exactly the installs it matters most for. Restores rate_max_base and clears the io_adjusted marker so plan.py's existing future-slot risk penalty doesn't still treat the minute as adjusted. 14 new tests (3 in test_rate_add_io_slots.py, 6 in the new test_exclude_dynamic_io_slots.py) plus the 17 pre-existing rate_add_io_slots tests updated to explicitly trust dynamic slots where that's what they're actually testing. All new tests confirmed to fail on the pre-fix code and pass with it. Full suite + pre-commit clean. --- apps/predbat/config.py | 7 ++ apps/predbat/fetch.py | 5 + apps/predbat/octopus.py | 60 +++++++++- apps/predbat/predbat.py | 1 + .../tests/test_exclude_dynamic_io_slots.py | 111 ++++++++++++++++++ apps/predbat/tests/test_rate_add_io_slots.py | 36 ++++++ apps/predbat/unit_test.py | 2 + docs/car-charging.md | 6 + 8 files changed, 225 insertions(+), 3 deletions(-) create mode 100644 apps/predbat/tests/test_exclude_dynamic_io_slots.py diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 24b85a570..8be5020e4 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -903,6 +903,13 @@ "default": False, "enable": "expert_mode", }, + { + "name": "trust_future_dynamic_iog_slots", + "friendly_name": "Trust future dynamic (daytime) Intelligent dispatch slots as cheap for the house battery", + "type": "switch", + "default": False, + "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 09345a532..dae27d2f3 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -981,6 +981,10 @@ def fetch_sensor_data(self, save=True): self.rate_import_no_io = import_rates.copy() for car_n in range(self.num_cars): import_rates = self.rate_add_io_slots(car_n, import_rates, self.octopus_slots[car_n]) + # #4516: undo any dynamic (out-of-window) IOG dispatch discount that arrived via the + # rate feed itself (self.io_adjusted) rather than rate_add_io_slots()'s own overlay - + # see exclude_dynamic_io_slots()'s docstring for why both sources need handling. + import_rates = self.exclude_dynamic_io_slots(import_rates) self.load_saving_slot(self.octopus_saving_slots, import_rates, export=False, rate_replicate=self.rate_import_replicated) self.load_free_slot(self.octopus_free_slots, import_rates, export=False, rate_replicate=self.rate_import_replicated) load_axle_slot(self, self.axle_sessions, import_rates, export=False, rate_replicate=self.rate_import_replicated) @@ -2522,6 +2526,7 @@ 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.trust_future_dynamic_iog_slots = self.get_arg("trust_future_dynamic_iog_slots") self.car_energy_reported_load = self.get_arg("car_energy_reported_load") self.get_car_charging_planned() self.load_inday_adjustment = 1.0 diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 399130563..413ac53a3 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2488,6 +2488,22 @@ def load_saving_slot(self, octopus_saving_slots, rate_dict, export=False, rate_r if not export: self.load_scaling_dynamic[minute] = self.load_scaling_saving + def minute_in_iog_fixed_window(self, minute_abs): + """ + True if minute_abs (minutes-since-midnight-of-today, may be negative or beyond + forecast_minutes) falls within the fixed IOG off-peak window (23:30-05:30), which is + guaranteed cheap by the tariff itself, not by the dispatch mechanism - so a slot inside it + is never at risk of being reclaimed the way an out-of-window dispatch slot is (#4516). + """ + window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"] + start_minute = window["start"][0] * 60 + window["start"][1] + end_minute = window["end"][0] * 60 + window["end"][1] + minute_of_day = minute_abs % 1440 + if window["cross_midnight"]: + return minute_of_day >= start_minute or minute_of_day < end_minute + else: + return start_minute <= minute_of_day < end_minute + def decode_octopus_slot(self, car_n, slot, raw=False): """ Decode IOG slot @@ -2723,6 +2739,7 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): """ octopus_slot_low_rate = self.get_arg("octopus_slot_low_rate", True) octopus_slot_max = self.get_arg("octopus_slot_max", OCTOPUS_SLOT_MAX_DEFAULT) + trust_future_dynamic_iog_slots = self.trust_future_dynamic_iog_slots # Track slots per 24-hour period (keyed by day offset from midday) # Period 0 = noon today to 11:59 tomorrow, Period -1 = noon yesterday to 11:59 today, etc. @@ -2775,9 +2792,15 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # Calculate the 30-min slot start for this minute slot_start = (minute // 30) * 30 + # A dynamic (out-of-window) dispatch slot is still Octopus's own + # provisional/revisable plan - it can be moved or rescinded before it + # occurs. The fixed 23:30-05:30 window is guaranteed cheap by the tariff + # itself, not by the dispatch mechanism, so it's never gated here (#4516). + trusted = trust_future_dynamic_iog_slots or self.minute_in_iog_fixed_window(slot_start) + # At the start of each 30-min slot, decide if we can add it if minute % 30 == 0: - if slots_per_day[day_offset] < octopus_slot_max: + if trusted and slots_per_day[day_offset] < octopus_slot_max: slots_per_day[day_offset] += 1 slots_added_set.add(slot_start) rates[minute] = assumed_price @@ -2790,8 +2813,8 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): if minute % 30 == 0 and start_minutes > -24 * 60: self.log( - "Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}".format( - self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate + "Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}, trusted {}".format( + self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate, trusted ) ) @@ -2802,6 +2825,37 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): return rates + def exclude_dynamic_io_slots(self, rates): + """ + When trust_future_dynamic_iog_slots is off, undo any IOG dispatch discount outside the + fixed 23:30-05:30 window that's already present in `rates` before rate_add_io_slots() ever + runs (#4516). + + rate_add_io_slots() only stops *itself* adding a new dynamic-slot discount - it can't + touch one that arrived a different way. For a genuine Octopus Intelligent tariff, + fetch_octopus_rates() can receive the dispatch-discounted rate directly from the rate feed + itself (marked via self.io_adjusted, from its own is_intelligent_adjusted flag), entirely + independently of the octopus_slots/octopus_intelligent_slot dispatch-list mechanism + rate_add_io_slots() reads. Leaving that alone would defeat the switch for exactly the + installs it matters most for. Restores rate_max_base - the true peak rate before any + saving-session/override/IOG distortion - and clears the io_adjusted marker so downstream + consumers (e.g. plan.py's future-slot risk penalty) don't still treat the minute as + IOG-adjusted once its discount has been removed. + + Runs once per cycle (not per car - rates/io_adjusted are shared, not per-car), after + rate_add_io_slots() has run for every car but before the independent saving-session/free/ + manual rate mechanisms apply their own adjustments on top. + """ + if self.trust_future_dynamic_iog_slots or not self.io_adjusted: + return rates + + for minute in list(self.io_adjusted.keys()): + if self.io_adjusted[minute] and not self.minute_in_iog_fixed_window(minute): + rates[minute] = self.rate_max_base + del self.io_adjusted[minute] + + return rates + def fetch_octopus_rates(self, entity_id, adjust_key=None): """ Fetch the Octopus rates from the sensor diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index b843a8a24..dba79db97 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -469,6 +469,7 @@ def reset(self): self.octopus_intelligent_charging = False self.octopus_intelligent_ignore_unplugged = False self.octopus_intelligent_consider_full = False + self.trust_future_dynamic_iog_slots = False self.notify_devices = ["notify"] self.octopus_url_cache = {} self.ge_url_cache = {} diff --git a/apps/predbat/tests/test_exclude_dynamic_io_slots.py b/apps/predbat/tests/test_exclude_dynamic_io_slots.py new file mode 100644 index 000000000..d6b4882a9 --- /dev/null +++ b/apps/predbat/tests/test_exclude_dynamic_io_slots.py @@ -0,0 +1,111 @@ +# ----------------------------------------------------------------------------- +# 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 + +""" +Unit tests for Octopus.exclude_dynamic_io_slots() (#4516) - undoes an IOG dispatch discount that +arrived via the rate feed itself (self.io_adjusted), independently of rate_add_io_slots()'s own +octopus_slots-driven overlay, for any minute outside the fixed 23:30-05:30 window when +trust_future_dynamic_iog_slots is off. +""" + + +def run_exclude_dynamic_io_slots_tests(my_predbat): + """ + Test for exclude_dynamic_io_slots() + """ + failed = False + print("**** Running exclude_dynamic_io_slots tests ****") + + saved_trust_dynamic = my_predbat.trust_future_dynamic_iog_slots + saved_io_adjusted = dict(my_predbat.io_adjusted) + my_predbat.rate_max_base = 30.0 + + try: + print("Test 1: a dynamic (out-of-window) io_adjusted minute is restored to rate_max_base and cleared") + my_predbat.trust_future_dynamic_iog_slots = False + my_predbat.io_adjusted = {840: True} # 14:00 - well outside 23:30-05:30 + rates = {840: 4.0} + result = my_predbat.exclude_dynamic_io_slots(rates) + if result.get(840) != 30.0: + print(" ERROR: expected minute 840 restored to rate_max_base 30.0, got {}".format(result.get(840))) + failed = True + if 840 in my_predbat.io_adjusted: + print(" ERROR: expected minute 840 cleared from io_adjusted, still present") + failed = True + + print("Test 2: a fixed-window io_adjusted minute is left untouched") + my_predbat.trust_future_dynamic_iog_slots = False + my_predbat.io_adjusted = {120: True} # 02:00 - inside 23:30-05:30 + rates = {120: 4.0} + result = my_predbat.exclude_dynamic_io_slots(rates) + if result.get(120) != 4.0: + print(" ERROR: expected minute 120 (fixed window) untouched at 4.0, got {}".format(result.get(120))) + failed = True + if 120 not in my_predbat.io_adjusted: + print(" ERROR: expected minute 120 to remain marked io_adjusted, was cleared") + failed = True + + print("Test 3: switch on leaves dynamic minutes untouched too") + my_predbat.trust_future_dynamic_iog_slots = True + my_predbat.io_adjusted = {840: True} + rates = {840: 4.0} + result = my_predbat.exclude_dynamic_io_slots(rates) + if result.get(840) != 4.0: + print(" ERROR: expected minute 840 untouched with switch on, got {}".format(result.get(840))) + failed = True + if 840 not in my_predbat.io_adjusted: + print(" ERROR: expected minute 840 to remain marked io_adjusted with switch on, was cleared") + failed = True + + print("Test 4: a falsy io_adjusted entry outside the window is left untouched") + my_predbat.trust_future_dynamic_iog_slots = False + my_predbat.io_adjusted = {840: False} + rates = {840: 4.0} + result = my_predbat.exclude_dynamic_io_slots(rates) + if result.get(840) != 4.0: + print(" ERROR: expected minute 840 (falsy io_adjusted) untouched, got {}".format(result.get(840))) + failed = True + + print("Test 5: empty io_adjusted does not crash and returns rates unchanged") + my_predbat.trust_future_dynamic_iog_slots = False + my_predbat.io_adjusted = {} + rates = {840: 4.0} + result = my_predbat.exclude_dynamic_io_slots(rates) + if result.get(840) != 4.0: + print(" ERROR: expected rates unchanged with empty io_adjusted, got {}".format(result.get(840))) + failed = True + + print("Test 6: multiple dynamic minutes are all restored, a mixed-in fixed-window minute is not") + my_predbat.trust_future_dynamic_iog_slots = False + my_predbat.io_adjusted = {840: True, 870: True, 120: True} + rates = {840: 4.0, 870: 4.0, 120: 4.0} + result = my_predbat.exclude_dynamic_io_slots(rates) + if result.get(840) != 30.0 or result.get(870) != 30.0: + print(" ERROR: expected both dynamic minutes restored, got {} and {}".format(result.get(840), result.get(870))) + failed = True + if result.get(120) != 4.0: + print(" ERROR: expected the fixed-window minute untouched, got {}".format(result.get(120))) + failed = True + if 840 in my_predbat.io_adjusted or 870 in my_predbat.io_adjusted: + print(" ERROR: expected both dynamic minutes cleared from io_adjusted") + failed = True + if 120 not in my_predbat.io_adjusted: + print(" ERROR: expected the fixed-window minute to remain in io_adjusted") + failed = True + finally: + my_predbat.trust_future_dynamic_iog_slots = saved_trust_dynamic + my_predbat.io_adjusted = saved_io_adjusted + + if failed: + print("\n**** exclude_dynamic_io_slots tests: FAILED ****") + else: + print("\n**** exclude_dynamic_io_slots tests: PASSED ****") + + return failed diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index 484f5ae1e..0de19012b 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -63,6 +63,12 @@ def run_rate_add_io_slots_tests(my_predbat): my_predbat.rate_min = 4 my_predbat.rate_min_base = 4 + # Tests 1-17 predate #4516's trust_future_dynamic_iog_slots gate and use daytime slots + # throughout to exercise the cap/dedup/low_rate mechanics specifically - not the new window + # gate, which tests 18-20 cover explicitly. Default to trusting dynamic slots here so tests + # 1-17 keep testing exactly what they were designed to test. + my_predbat.trust_future_dynamic_iog_slots = True + # Test 1: Simple single slot within limit print("\n**** Test 1: Single 30-min slot ****") slot_start = midnight_utc + timedelta(hours=2) @@ -345,6 +351,36 @@ def run_rate_add_io_slots_tests(my_predbat): expected_rates_17[minute] = 4.0 failed |= run_rate_add_io_slots_test("test17_dup_does_not_waste_cap", my_predbat, slots_17, True, 2, expected_rates_17) + # Tests 18-20 (#4516): trust_future_dynamic_iog_slots - a dynamic (out-of-window) daytime + # dispatch slot is still Octopus's own provisional/revisable plan and can be moved or rescinded + # before it occurs, so by default it must not be treated as guaranteed cheap for the house + # battery. The fixed 23:30-05:30 window is guaranteed cheap by the tariff itself and is never + # affected regardless of the switch. + saved_trust_dynamic = my_predbat.trust_future_dynamic_iog_slots + + print("\n**** Test 18: Dynamic daytime slot is not low rate by default ****") + my_predbat.trust_future_dynamic_iog_slots = False + slot_start_18 = midnight_utc + timedelta(hours=14) # 14:00 - well outside the fixed window + slot_end_18 = slot_start_18 + timedelta(minutes=30) + slots_18 = [{"start": slot_start_18.strftime(TIME_FORMAT), "end": slot_end_18.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}] + expected_rates_18 = {minute: 10.0 for minute in range(840, 870)} # left at the normal rate + failed |= run_rate_add_io_slots_test("test18_dynamic_slot_not_trusted_by_default", my_predbat, slots_18, True, 12, expected_rates_18) + + print("\n**** Test 19: Fixed 23:30-05:30 window slot stays low rate regardless of the switch ****") + my_predbat.trust_future_dynamic_iog_slots = False + slot_start_19 = midnight_utc + timedelta(hours=2) # 02:00 - well inside 23:30-05:30 + slot_end_19 = slot_start_19 + timedelta(minutes=30) + slots_19 = [{"start": slot_start_19.strftime(TIME_FORMAT), "end": slot_end_19.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}] + expected_rates_19 = {minute: 4.0 for minute in range(120, 150)} + failed |= run_rate_add_io_slots_test("test19_fixed_window_always_trusted", my_predbat, slots_19, True, 12, expected_rates_19) + + print("\n**** Test 20: Switch on restores unconditional trust of dynamic slots ****") + my_predbat.trust_future_dynamic_iog_slots = True + expected_rates_20 = {minute: 4.0 for minute in range(840, 870)} + failed |= run_rate_add_io_slots_test("test20_switch_on_trusts_dynamic_slots", my_predbat, slots_18, True, 12, expected_rates_20) + + my_predbat.trust_future_dynamic_iog_slots = saved_trust_dynamic + # Restore original forecast_minutes my_predbat.forecast_minutes = original_forecast_minutes diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 3951cbcf7..194dd49f3 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -175,6 +175,7 @@ from tests.test_rate_add_io_slots import run_rate_add_io_slots_tests from tests.test_iog_charge_skew import run_iog_charge_skew_tests from tests.test_dispatch_timeline import run_dispatch_timeline_tests +from tests.test_exclude_dynamic_io_slots import run_exclude_dynamic_io_slots_tests from tests.test_battery_curve_keys import run_battery_curve_keys_tests from tests.test_balance_inverters import run_balance_inverters_tests from tests.test_octopus_download_rates import test_octopus_download_rates_wrapper @@ -423,6 +424,7 @@ def main(): ("rate_add_io_slots", run_rate_add_io_slots_tests, "Rate add IO slots tests", False), ("iog_charge_skew", run_iog_charge_skew_tests, "IOG earlier-charge skew characterisation tests", False), ("dispatch_timeline", run_dispatch_timeline_tests, "Dispatch timeline diagnostic tests (#4516 Stage 1)", False), + ("exclude_dynamic_io_slots", run_exclude_dynamic_io_slots_tests, "Exclude dynamic IOG slots tests (#4516)", False), ("rate_replicate", test_rate_replicate, "Rate replicate comprehensive tests (missing slots, IO, offsets, gas)", False), ("find_charge_window", test_find_charge_window, "Find charge window gap handling tests", False), ("find_charge_rate", test_find_charge_rate, "Find charge rate tests", False), diff --git a/docs/car-charging.md b/docs/car-charging.md index 6adcafedb..1b8cf8fb0 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -352,6 +352,12 @@ 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_trust_future_dynamic_iog_slots** (*expert mode*) (default value is Off) controls whether a future daytime Intelligent dispatch slot (outside the fixed 23:30-05:30 off-peak window) is treated as a guaranteed cheap rate for the **house battery** plan. +A daytime dispatch is still Octopus's own provisional plan until it actually happens - it can be moved or withdrawn beforehand, which could otherwise lead Predbat to make an irreversible decision (e.g. an early force-export) in anticipation of a cheap recharge that never occurs. +With the switch Off (the default), only the fixed 23:30-05:30 window and dispatches that have already started or completed are trusted as cheap for battery planning purposes; a still-provisional future daytime slot is left at the normal rate instead. +This does not affect car charging: future dispatch slots are still used in full to forecast when your EV will charge, regardless of this switch - only whether they're trusted as a cheap rate for the house battery is affected. +Turn this On to restore the previous behaviour, where every dispatch slot Octopus reports is trusted as cheap for the house battery the moment it's known, whether provisional or confirmed. + - Let the Octopus app control when your car charges. ### Predbat-led charging From 82c0be4704cfc4ce8ebc0882308927fb81df81fe Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 15 Aug 2026 09:35:02 +0100 Subject: [PATCH 03/11] fix(octopus): trust a dynamic IOG slot already underway or past, regardless of the switch Follow-up to the trust_future_dynamic_iog_slots switch: rate_add_io_slots() and exclude_dynamic_io_slots() gated every out-of-window dynamic slot identically, past or future. But today_cost() (output.py) computes today's actual spend - both house and per-car breakdown - by multiplying real historical import/car energy against self.rate_import for every already- elapsed minute today. With the switch off, a genuine dynamic dispatch that had already happened today would get excluded from the rate calculation, making the actual-spend figures wrongly report it at full price - #4516's rescission risk only applies to a slot that hasn't happened yet. Both functions now also trust any slot at or before the current 30-min settlement period (current_block = (minutes_now // 30) * 30), regardless of the switch or fixed-window check - once a slot has started or completed, the risk it existed to guard against has already resolved one way or the other. 2 new tests (one per function), each confirmed to fail on the pre-fix code and pass with it. Full suite + pre-commit clean. --- apps/predbat/octopus.py | 20 +++++++++++++++++-- .../tests/test_exclude_dynamic_io_slots.py | 19 ++++++++++++++++++ apps/predbat/tests/test_rate_add_io_slots.py | 12 +++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 413ac53a3..b6a3e8248 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2740,6 +2740,12 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): octopus_slot_low_rate = self.get_arg("octopus_slot_low_rate", True) octopus_slot_max = self.get_arg("octopus_slot_max", OCTOPUS_SLOT_MAX_DEFAULT) trust_future_dynamic_iog_slots = self.trust_future_dynamic_iog_slots + # The start of the current 30-min settlement period. A slot at or before this has already + # started or completed - #4516's risk (Octopus withdrawing a still-provisional slot before + # it happens) has already resolved one way or the other, and today_cost()'s actual-spend + # figures (house and car) need genuine historical rates regardless of the switch, or a real + # dynamic dispatch that already happened today would wrongly report as full price. + current_block = (self.minutes_now // 30) * 30 # Track slots per 24-hour period (keyed by day offset from midday) # Period 0 = noon today to 11:59 tomorrow, Period -1 = noon yesterday to 11:59 today, etc. @@ -2796,7 +2802,10 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # provisional/revisable plan - it can be moved or rescinded before it # occurs. The fixed 23:30-05:30 window is guaranteed cheap by the tariff # itself, not by the dispatch mechanism, so it's never gated here (#4516). - trusted = trust_future_dynamic_iog_slots or self.minute_in_iog_fixed_window(slot_start) + # A slot already underway or in the past is trusted regardless - its + # rescission risk has already resolved, and its rate must reflect what + # genuinely happened for today_cost()'s actual-spend figures to be correct. + trusted = trust_future_dynamic_iog_slots or self.minute_in_iog_fixed_window(slot_start) or (slot_start <= current_block) # At the start of each 30-min slot, decide if we can add it if minute % 30 == 0: @@ -2845,12 +2854,19 @@ def exclude_dynamic_io_slots(self, rates): Runs once per cycle (not per car - rates/io_adjusted are shared, not per-car), after rate_add_io_slots() has run for every car but before the independent saving-session/free/ manual rate mechanisms apply their own adjustments on top. + + A minute already underway or in the past is left untouched regardless of the switch - + see rate_add_io_slots()'s matching carve-out for why: today_cost()'s actual-spend figures + need genuine historical rates, not ones retroactively excluded by this switch. """ if self.trust_future_dynamic_iog_slots or not self.io_adjusted: return rates + current_block = (self.minutes_now // 30) * 30 + for minute in list(self.io_adjusted.keys()): - if self.io_adjusted[minute] and not self.minute_in_iog_fixed_window(minute): + slot_start = (minute // 30) * 30 + if self.io_adjusted[minute] and not self.minute_in_iog_fixed_window(minute) and slot_start > current_block: rates[minute] = self.rate_max_base del self.io_adjusted[minute] diff --git a/apps/predbat/tests/test_exclude_dynamic_io_slots.py b/apps/predbat/tests/test_exclude_dynamic_io_slots.py index d6b4882a9..68bf2502d 100644 --- a/apps/predbat/tests/test_exclude_dynamic_io_slots.py +++ b/apps/predbat/tests/test_exclude_dynamic_io_slots.py @@ -25,7 +25,9 @@ def run_exclude_dynamic_io_slots_tests(my_predbat): saved_trust_dynamic = my_predbat.trust_future_dynamic_iog_slots saved_io_adjusted = dict(my_predbat.io_adjusted) + saved_minutes_now = my_predbat.minutes_now my_predbat.rate_max_base = 30.0 + my_predbat.minutes_now = 600 # 10:00, so current_block = 600 try: print("Test 1: a dynamic (out-of-window) io_adjusted minute is restored to rate_max_base and cleared") @@ -99,9 +101,26 @@ def run_exclude_dynamic_io_slots_tests(my_predbat): if 120 not in my_predbat.io_adjusted: print(" ERROR: expected the fixed-window minute to remain in io_adjusted") failed = True + + # Test 7 (#4516 follow-up): a dynamic io_adjusted minute already underway or in the past + # (minutes_now=600, so current_block=600) is left untouched regardless of the switch - + # today_cost()'s actual-spend figures need genuine historical rates, not ones retroactively + # excluded once we know the dispatch already happened. + print("Test 7: a past/current dynamic io_adjusted minute is left untouched regardless of the switch") + my_predbat.trust_future_dynamic_iog_slots = False + my_predbat.io_adjusted = {570: True} # 09:30 - before minutes_now (10:00), outside the fixed window + rates = {570: 4.0} + result = my_predbat.exclude_dynamic_io_slots(rates) + if result.get(570) != 4.0: + print(" ERROR: expected past minute 570 untouched at 4.0, got {}".format(result.get(570))) + failed = True + if 570 not in my_predbat.io_adjusted: + print(" ERROR: expected past minute 570 to remain marked io_adjusted, was cleared") + failed = True finally: my_predbat.trust_future_dynamic_iog_slots = saved_trust_dynamic my_predbat.io_adjusted = saved_io_adjusted + my_predbat.minutes_now = saved_minutes_now if failed: print("\n**** exclude_dynamic_io_slots tests: FAILED ****") diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index 0de19012b..831f6dfe4 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -379,6 +379,18 @@ def run_rate_add_io_slots_tests(my_predbat): expected_rates_20 = {minute: 4.0 for minute in range(840, 870)} failed |= run_rate_add_io_slots_test("test20_switch_on_trusts_dynamic_slots", my_predbat, slots_18, True, 12, expected_rates_20) + # Test 21 (#4516 follow-up): a dynamic slot already underway or in the past is trusted + # regardless of the switch - its rescission risk has already resolved one way or the other, + # and today_cost()'s actual-spend figures (house and car) need genuine historical rates or a + # real dynamic dispatch that already happened today would wrongly report as full price. + print("\n**** Test 21: A past/current dynamic slot is trusted regardless of the switch ****") + my_predbat.trust_future_dynamic_iog_slots = False + slot_start_21 = midnight_utc + timedelta(hours=9) # 09:00 - before minutes_now (10:00), outside the fixed window + slot_end_21 = slot_start_21 + timedelta(minutes=30) + slots_21 = [{"start": slot_start_21.strftime(TIME_FORMAT), "end": slot_end_21.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "unknown", "location": "AT_HOME"}] + expected_rates_21 = {minute: 4.0 for minute in range(540, 570)} + failed |= run_rate_add_io_slots_test("test21_past_dynamic_slot_always_trusted", my_predbat, slots_21, True, 12, expected_rates_21) + my_predbat.trust_future_dynamic_iog_slots = saved_trust_dynamic # Restore original forecast_minutes From 45920b612d6c94f36e56a87644958745d3f5fa3c Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 15 Aug 2026 09:37:44 +0100 Subject: [PATCH 04/11] docs(car-charging): explain why trust_future_dynamic_iog_slots doesn't touch past cost reporting Spell out that the switch only ever withholds trust from a slot that hasn't happened yet - a started/completed dispatch is always trusted regardless - and why: today's actual-spend figures (house and car) are calculated from the same rates, so this never rewrites what genuinely already happened. --- docs/car-charging.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/car-charging.md b/docs/car-charging.md index 1b8cf8fb0..fc9783e9d 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -355,6 +355,8 @@ This will only work correctly if **car_charging_planned** is set correctly in `a - The switch **switch.predbat_trust_future_dynamic_iog_slots** (*expert mode*) (default value is Off) controls whether a future daytime Intelligent dispatch slot (outside the fixed 23:30-05:30 off-peak window) is treated as a guaranteed cheap rate for the **house battery** plan. A daytime dispatch is still Octopus's own provisional plan until it actually happens - it can be moved or withdrawn beforehand, which could otherwise lead Predbat to make an irreversible decision (e.g. an early force-export) in anticipation of a cheap recharge that never occurs. With the switch Off (the default), only the fixed 23:30-05:30 window and dispatches that have already started or completed are trusted as cheap for battery planning purposes; a still-provisional future daytime slot is left at the normal rate instead. +A slot is only ever held back while it's still in the future and unconfirmed - the moment it starts (or if it's already in the past), it's trusted regardless of this switch. +This matters beyond the plan itself: Predbat's "today's actual cost so far" figures (for both the house and, separately, your car's own charging cost) are calculated from these same rates against what was genuinely imported today, so a dispatch that has already happened is always reflected accurately - this switch only ever withholds trust from a slot that hasn't happened yet, never rewrites what already did. This does not affect car charging: future dispatch slots are still used in full to forecast when your EV will charge, regardless of this switch - only whether they're trusted as a cheap rate for the house battery is affected. Turn this On to restore the previous behaviour, where every dispatch slot Octopus reports is trusted as cheap for the house battery the moment it's known, whether provisional or confirmed. From 5fb6998a33f160109db1eb9544d5e06969fb6c7c Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 15 Aug 2026 12:37:04 +0100 Subject: [PATCH 05/11] docs(car-charging): remove stale claim that car_charging_from_battery auto-set On f3ddd504 (#4246/#4264) already fixed the code so battery-discharge-into-car protection applies regardless of car_energy_reported_load - its own default (Off) already prevents this unconditionally. This paragraph was never updated to match and still claimed the opposite (that car_energy_reported_load Off would auto-set car_charging_from_battery On because the circuits were assumed separate). Confirmed no such auto-set exists anywhere in the code (execute.py and prediction.py both gate discharge-hold on car_charging_from_battery alone, explicitly documented as independent of car_energy_reported_load). --- docs/car-charging.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/car-charging.md b/docs/car-charging.md index fc9783e9d..91a4c95a8 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -45,9 +45,10 @@ high battery charge levels when the car was charged previously (e.g. last week). - **On (default)** - The car charger is inside the CT clamp and its energy is reported as part of the house load. Predbat will attempt to strip car charging energy from historical load data (via **switch.predbat_car_charging_hold**) and will prevent the battery from discharging into the car when **switch.predbat_car_charging_from_battery** is Off. - **Off** - The car charger is wired outside the CT clamp (e.g. directly to the grid connection). The inverter cannot see the car charging load directly. In this case Predbat will: - Automatically disable **switch.predbat_car_charging_hold** (no need to strip car data from house load, as it was never included). - - Automatically set **switch.predbat_car_charging_from_battery** to On in the model (the battery cannot discharge into the car anyway as it is on a separate circuit). - Model that any export from the battery/PV may flow into the car rather than the grid, and so conservatively not credit that energy with export income. + Being outside the CT clamp only means the charger's draw isn't *measured* as house load - it says nothing about whether it's *electrically* isolated from the battery. Predbat does not assume the two are on separate circuits, and does not change **switch.predbat_car_charging_from_battery** based on this switch: its own default (Off) already prevents the battery discharging into car charging regardless of **car_energy_reported_load**, and that protection applies unconditionally - see **switch.predbat_car_charging_from_battery** below. If your installation genuinely has the charger wired so the battery *can* supply it (e.g. sharing a busbar upstream of the meter), leave **car_charging_from_battery** Off (or set it explicitly) rather than relying on any automatic behaviour from this switch. + - **switch.predbat_car_charging_hold** - A switch that when turned On (the default) tells Predbat to remove car charging data from your historical house load so that Predbat's battery prediction plan is not distorted by previous car charging. This switch is automatically overridden to Off when **switch.predbat_car_energy_reported_load** is Off, since the car load is not in the house load data. If you are getting [erroneous house load predictions in your plan](faq.md#why-is-my-house-load-lower-than-expected-or-zero) then check this setting and **car_charging_energy** or **input_number.predbat_car_charging_threshold** are set correctly. From bb08f54b8f4f09bbb0e8f51ef304a5aaf9eff2cb Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 15 Aug 2026 13:15:03 +0100 Subject: [PATCH 06/11] fix(octopus): rework trust_future_dynamic_iog_slots as a 3-way select (#4516) Clock time alone isn't proof a dynamic IOG slot actually happened - Octopus can still revoke it right up until it's confirmed, so 237a03c1's "trust it once its start time has passed" carve-out was wrong and is superseded here. Trust is now source-based: decode_octopus_slot() threads a _confirmed tag (set on completed_dispatches merge, or on the car_charging_now synthetic slot) through to rate_add_io_slots(), which now supports three levels: - none: never trust a dynamic (outside the fixed 23:30-05:30) slot - completed: trust once Octopus reports it as completed_dispatches - started: also trust the current 30-min block when car_charging_now is true, since waiting for "completed" can be too slow to act on exclude_dynamic_io_slots() now consults self.trusted_dynamic_minutes (built by rate_add_io_slots()) instead of re-deriving trust from clock time, so the two functions can't disagree. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/config.py | 7 +- apps/predbat/fetch.py | 24 +++- apps/predbat/octopus.py | 82 ++++++++----- apps/predbat/predbat.py | 4 +- .../tests/test_exclude_dynamic_io_slots.py | 66 +++++----- apps/predbat/tests/test_rate_add_io_slots.py | 113 ++++++++++++------ 6 files changed, 184 insertions(+), 112 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 8be5020e4..bd9f9b040 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -905,9 +905,10 @@ }, { "name": "trust_future_dynamic_iog_slots", - "friendly_name": "Trust future dynamic (daytime) Intelligent dispatch slots as cheap for the house battery", - "type": "switch", - "default": False, + "friendly_name": "When to trust a dynamic (daytime) Intelligent dispatch slot as cheap for the house battery", + "type": "select", + "options": ["none", "completed", "started"], + "default": "none", "enable": "expert_mode", }, { diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index dae27d2f3..2fc36919c 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -714,6 +714,10 @@ def fetch_sensor_data(self, save=True): self.rate_export_replicated = {} self.rate_slots = [] self.io_adjusted = {} + # Built up by rate_add_io_slots() (per car) as it decides which dynamic-slot minutes + # trust_future_dynamic_iog_slots trusts; consumed by exclude_dynamic_io_slots() afterwards + # so both agree on the same trust decisions (#4516). + self.trusted_dynamic_minutes = set() self.low_rates = [] self.high_export_rates = [] self.octopus_slots = [[] for _ in range(self.num_cars)] @@ -1228,12 +1232,16 @@ def fetch_sensor_data_cars(self): timeline = self.build_dispatch_timeline(car_n, completed, started, planned) self.log("Octopus: Dispatch timeline car {} @ {} [-4h..+24h]: {}".format(car_n, self.time_abs_str(self.minutes_now), timeline)) - # Completed and planned slots - merge from all cars + # Completed and planned slots - merge from all cars. Tag provenance (copies, not + # in-place mutation, since get_state_wrapper may return a cached/shared list): + # completed dispatches are Octopus's metered, confirmed record; planned dispatches + # are still Octopus's own provisional schedule. See rate_add_io_slots() for how + # this gates trust_future_dynamic_iog_slots's "completed"/"started" levels (#4516). if completed: - self.octopus_slots[car_n] += completed + self.octopus_slots[car_n] += [dict(slot, _confirmed=True) for slot in completed] if planned and (not self.octopus_intelligent_ignore_unplugged or self.car_charging_planned[car_n]): # We only count planned slots if the car is plugged in or we are ignoring unplugged cars - self.octopus_slots[car_n] += planned + self.octopus_slots[car_n] += [dict(slot, _confirmed=False) for slot in planned] # Extract vehicle data if we can get it size = self.get_state_wrapper(entity_id=entity_id, attribute="vehicle_battery_size_in_kwh") @@ -2527,6 +2535,16 @@ def fetch_config_options(self): 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.trust_future_dynamic_iog_slots = self.get_arg("trust_future_dynamic_iog_slots") + if self.trust_future_dynamic_iog_slots == "started" and "car_charging_now" not in self.args: + # Without car_charging_now, "started" has nothing to check beyond what "completed" + # already covers - degrades gracefully (self.car_charging_now defaults False per car) + # rather than failing, but silently, so warn once so it's not mistaken for working. + if not self.trust_future_dynamic_iog_slots_warned: + self.log("Warn: trust_future_dynamic_iog_slots is set to 'started' but car_charging_now is not configured in apps.yaml - it will behave the same as 'completed' until car_charging_now is set") + self.trust_future_dynamic_iog_slots_warned = True + self.record_status("Warn: trust_future_dynamic_iog_slots is 'started' but car_charging_now is not configured - behaves as 'completed'", had_errors=True) + else: + self.trust_future_dynamic_iog_slots_warned = False self.car_energy_reported_load = self.get_arg("car_energy_reported_load") self.get_car_charging_planned() self.load_inday_adjustment = 1.0 diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index b6a3e8248..0c4cdfac9 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2358,6 +2358,7 @@ def add_now_to_octopus_slot(self, car_n, octopus_slots, now_utc): slot["end"] = slot_end_date.strftime(TIME_FORMAT) slot["source"] = "car_charging_now" slot["kwh"] = self.car_charging_rate[car_n] * 30 / 60 # Scale to 30 minute slot + slot["_confirmed"] = True # Real-time confirmed draw, not a still-revisable Octopus plan octopus_slots.append(slot) self.log("Octopus: Car is charging now - added new IO slot {}".format(slot)) return octopus_slots @@ -2517,6 +2518,11 @@ def decode_octopus_slot(self, car_n, slot, raw=False): source = slot.get("source", "") location = slot.get("location", "") + # Whether this slot is genuinely confirmed (completed_dispatches, or a real-time + # car_charging_now draw) versus still Octopus's own provisional plan (planned_dispatches) - + # see fetch_sensor_data_cars() where this is tagged. Defaults False for anything untagged + # (e.g. the action_config service-call path, which only ever returns planned slots). + confirmed = slot.get("_confirmed", False) start_minutes = minutes_to_time(start, self.midnight_utc) end_minutes = minutes_to_time(end, self.midnight_utc) @@ -2528,7 +2534,7 @@ def decode_octopus_slot(self, car_n, slot, raw=False): end_minutes = max(min(end_minutes, self.forecast_minutes + self.minutes_now), start_minutes) if start_minutes == end_minutes: - return 0, 0, 0, source, location + return 0, 0, 0, source, location, confirmed cap_minutes = end_minutes - start_minutes @@ -2542,7 +2548,7 @@ def decode_octopus_slot(self, car_n, slot, raw=False): # Remove empty slots if kwh is None and location == "" and source == "": - return 0, 0, 0, source, location + return 0, 0, 0, source, location, confirmed # Create kWh if missing if kwh is None: @@ -2558,7 +2564,7 @@ def decode_octopus_slot(self, car_n, slot, raw=False): else: kwh = 0 - return start_minutes, end_minutes, kwh, source, location + return start_minutes, end_minutes, kwh, source, location, confirmed def build_dispatch_timeline(self, car_n, completed, started, planned, window_before_hours=4, window_after_hours=24, step=30): """ @@ -2583,7 +2589,7 @@ def build_dispatch_timeline(self, car_n, completed, started, planned, window_bef def mark(slots, char): for slot in slots or []: - start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True) + start_minutes, end_minutes, _, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True) if start_minutes == end_minutes: continue start_offset = start_minutes - self.minutes_now @@ -2616,7 +2622,7 @@ def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_ # Decode the slots for slot in octopus_slots: - start_minutes, end_minutes, kwh, source, location = self.decode_octopus_slot(car_n, slot) + start_minutes, end_minutes, kwh, source, location, _ = self.decode_octopus_slot(car_n, slot) # Octopus zeros chargeKwh once it calculates the car has hit its target SoC, but the # dispatch window stays open and the charger may still draw power. Preserve active slots # with a duration-based kwh so the "Hold for car" guard in execute.py still fires. @@ -2740,11 +2746,9 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): octopus_slot_low_rate = self.get_arg("octopus_slot_low_rate", True) octopus_slot_max = self.get_arg("octopus_slot_max", OCTOPUS_SLOT_MAX_DEFAULT) trust_future_dynamic_iog_slots = self.trust_future_dynamic_iog_slots - # The start of the current 30-min settlement period. A slot at or before this has already - # started or completed - #4516's risk (Octopus withdrawing a still-provisional slot before - # it happens) has already resolved one way or the other, and today_cost()'s actual-spend - # figures (house and car) need genuine historical rates regardless of the switch, or a real - # dynamic dispatch that already happened today would wrongly report as full price. + # The start of the current 30-min settlement period - only this one can be corroborated by + # a live car_charging_now reading (#4516's "started" trust level); car_charging_now reflects + # "now", so it says nothing about any other period, past or future. current_block = (self.minutes_now // 30) * 30 # Track slots per 24-hour period (keyed by day offset from midday) @@ -2759,7 +2763,7 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): if octopus_slots: # Add in IO slots for slot in octopus_slots: - start_minutes, end_minutes, kwh, source, location = self.decode_octopus_slot(car_n, slot, raw=True) + start_minutes, end_minutes, kwh, source, location, confirmed = self.decode_octopus_slot(car_n, slot, raw=True) # Ignore bump-charge slots as their cost won't change if source != "bump-charge" and source != "BOOST" and (not location or location == "AT_HOME"): @@ -2802,10 +2806,28 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # provisional/revisable plan - it can be moved or rescinded before it # occurs. The fixed 23:30-05:30 window is guaranteed cheap by the tariff # itself, not by the dispatch mechanism, so it's never gated here (#4516). - # A slot already underway or in the past is trusted regardless - its - # rescission risk has already resolved, and its rate must reflect what - # genuinely happened for today_cost()'s actual-spend figures to be correct. - trusted = trust_future_dynamic_iog_slots or self.minute_in_iog_fixed_window(slot_start) or (slot_start <= current_block) + # + # For a dynamic slot, trust is graduated by trust_future_dynamic_iog_slots: + # "none" never trusts one; "completed" also trusts one genuinely confirmed + # (completed_dispatches, or a real-time car_charging_now draw - see + # decode_octopus_slot()); "started" additionally trusts the *current* + # settlement period the moment car_charging_now shows the car drawing power + # right now, without waiting for Octopus's own completed record to catch up. + # A slot merely having reached its scheduled start time is deliberately NOT + # enough on its own - the clock passing a boundary is not evidence the car + # actually started, only that it was due to. + in_fixed_window = self.minute_in_iog_fixed_window(slot_start) + if in_fixed_window: + trusted = True + elif trust_future_dynamic_iog_slots == "completed": + trusted = confirmed + elif trust_future_dynamic_iog_slots == "started": + trusted = confirmed or (slot_start == current_block and car_n < len(self.car_charging_now) and self.car_charging_now[car_n]) + else: # "none" + trusted = False + + if trusted and not in_fixed_window: + self.trusted_dynamic_minutes.add(minute) # At the start of each 30-min slot, decide if we can add it if minute % 30 == 0: @@ -2822,8 +2844,8 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): if minute % 30 == 0 and start_minutes > -24 * 60: self.log( - "Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}, trusted {}".format( - self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate, trusted + "Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}, confirmed {}, trusted {}".format( + self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate, confirmed, trusted ) ) @@ -2836,12 +2858,12 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): def exclude_dynamic_io_slots(self, rates): """ - When trust_future_dynamic_iog_slots is off, undo any IOG dispatch discount outside the - fixed 23:30-05:30 window that's already present in `rates` before rate_add_io_slots() ever - runs (#4516). + Undo any IOG dispatch discount outside the fixed 23:30-05:30 window that's already present + in `rates` before rate_add_io_slots() ever runs, for a minute trust_future_dynamic_iog_slots + wouldn't itself have trusted (#4516). - rate_add_io_slots() only stops *itself* adding a new dynamic-slot discount - it can't - touch one that arrived a different way. For a genuine Octopus Intelligent tariff, + rate_add_io_slots() only stops *itself* adding a new dynamic-slot discount - it can't touch + one that arrived a different way. For a genuine Octopus Intelligent tariff, fetch_octopus_rates() can receive the dispatch-discounted rate directly from the rate feed itself (marked via self.io_adjusted, from its own is_intelligent_adjusted flag), entirely independently of the octopus_slots/octopus_intelligent_slot dispatch-list mechanism @@ -2853,20 +2875,16 @@ def exclude_dynamic_io_slots(self, rates): Runs once per cycle (not per car - rates/io_adjusted are shared, not per-car), after rate_add_io_slots() has run for every car but before the independent saving-session/free/ - manual rate mechanisms apply their own adjustments on top. - - A minute already underway or in the past is left untouched regardless of the switch - - see rate_add_io_slots()'s matching carve-out for why: today_cost()'s actual-spend figures - need genuine historical rates, not ones retroactively excluded by this switch. + manual rate mechanisms apply their own adjustments on top. Consults + self.trusted_dynamic_minutes - the set rate_add_io_slots() itself just built while deciding + which dynamic minutes it trusted, across all cars - rather than re-deriving trust here, so + the two functions can never disagree about what counts as trusted. """ - if self.trust_future_dynamic_iog_slots or not self.io_adjusted: + if not self.io_adjusted: return rates - current_block = (self.minutes_now // 30) * 30 - for minute in list(self.io_adjusted.keys()): - slot_start = (minute // 30) * 30 - if self.io_adjusted[minute] and not self.minute_in_iog_fixed_window(minute) and slot_start > current_block: + if self.io_adjusted[minute] and not self.minute_in_iog_fixed_window(minute) and minute not in self.trusted_dynamic_minutes: rates[minute] = self.rate_max_base del self.io_adjusted[minute] diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index dba79db97..ac146a098 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -469,7 +469,9 @@ def reset(self): self.octopus_intelligent_charging = False self.octopus_intelligent_ignore_unplugged = False self.octopus_intelligent_consider_full = False - self.trust_future_dynamic_iog_slots = False + self.trust_future_dynamic_iog_slots = "none" + self.trust_future_dynamic_iog_slots_warned = False + self.trusted_dynamic_minutes = set() self.notify_devices = ["notify"] self.octopus_url_cache = {} self.ge_url_cache = {} diff --git a/apps/predbat/tests/test_exclude_dynamic_io_slots.py b/apps/predbat/tests/test_exclude_dynamic_io_slots.py index 68bf2502d..4c7a5e4e9 100644 --- a/apps/predbat/tests/test_exclude_dynamic_io_slots.py +++ b/apps/predbat/tests/test_exclude_dynamic_io_slots.py @@ -11,8 +11,9 @@ """ Unit tests for Octopus.exclude_dynamic_io_slots() (#4516) - undoes an IOG dispatch discount that arrived via the rate feed itself (self.io_adjusted), independently of rate_add_io_slots()'s own -octopus_slots-driven overlay, for any minute outside the fixed 23:30-05:30 window when -trust_future_dynamic_iog_slots is off. +octopus_slots-driven overlay, for any dynamic minute rate_add_io_slots() didn't itself trust - +consulting self.trusted_dynamic_minutes (built by rate_add_io_slots()) rather than re-deriving +trust here, so the two functions can never disagree. """ @@ -23,15 +24,13 @@ def run_exclude_dynamic_io_slots_tests(my_predbat): failed = False print("**** Running exclude_dynamic_io_slots tests ****") - saved_trust_dynamic = my_predbat.trust_future_dynamic_iog_slots saved_io_adjusted = dict(my_predbat.io_adjusted) - saved_minutes_now = my_predbat.minutes_now + saved_trusted_dynamic_minutes = set(my_predbat.trusted_dynamic_minutes) my_predbat.rate_max_base = 30.0 - my_predbat.minutes_now = 600 # 10:00, so current_block = 600 try: - print("Test 1: a dynamic (out-of-window) io_adjusted minute is restored to rate_max_base and cleared") - my_predbat.trust_future_dynamic_iog_slots = False + print("Test 1: a dynamic io_adjusted minute not in trusted_dynamic_minutes is restored and cleared") + my_predbat.trusted_dynamic_minutes = set() my_predbat.io_adjusted = {840: True} # 14:00 - well outside 23:30-05:30 rates = {840: 4.0} result = my_predbat.exclude_dynamic_io_slots(rates) @@ -42,8 +41,8 @@ def run_exclude_dynamic_io_slots_tests(my_predbat): print(" ERROR: expected minute 840 cleared from io_adjusted, still present") failed = True - print("Test 2: a fixed-window io_adjusted minute is left untouched") - my_predbat.trust_future_dynamic_iog_slots = False + print("Test 2: a fixed-window io_adjusted minute is left untouched even if untrusted") + my_predbat.trusted_dynamic_minutes = set() my_predbat.io_adjusted = {120: True} # 02:00 - inside 23:30-05:30 rates = {120: 4.0} result = my_predbat.exclude_dynamic_io_slots(rates) @@ -54,20 +53,20 @@ def run_exclude_dynamic_io_slots_tests(my_predbat): print(" ERROR: expected minute 120 to remain marked io_adjusted, was cleared") failed = True - print("Test 3: switch on leaves dynamic minutes untouched too") - my_predbat.trust_future_dynamic_iog_slots = True + print("Test 3: a dynamic io_adjusted minute that IS in trusted_dynamic_minutes is left untouched") + my_predbat.trusted_dynamic_minutes = {840} my_predbat.io_adjusted = {840: True} rates = {840: 4.0} result = my_predbat.exclude_dynamic_io_slots(rates) if result.get(840) != 4.0: - print(" ERROR: expected minute 840 untouched with switch on, got {}".format(result.get(840))) + print(" ERROR: expected trusted minute 840 untouched, got {}".format(result.get(840))) failed = True if 840 not in my_predbat.io_adjusted: - print(" ERROR: expected minute 840 to remain marked io_adjusted with switch on, was cleared") + print(" ERROR: expected trusted minute 840 to remain marked io_adjusted, was cleared") failed = True print("Test 4: a falsy io_adjusted entry outside the window is left untouched") - my_predbat.trust_future_dynamic_iog_slots = False + my_predbat.trusted_dynamic_minutes = set() my_predbat.io_adjusted = {840: False} rates = {840: 4.0} result = my_predbat.exclude_dynamic_io_slots(rates) @@ -76,7 +75,7 @@ def run_exclude_dynamic_io_slots_tests(my_predbat): failed = True print("Test 5: empty io_adjusted does not crash and returns rates unchanged") - my_predbat.trust_future_dynamic_iog_slots = False + my_predbat.trusted_dynamic_minutes = set() my_predbat.io_adjusted = {} rates = {840: 4.0} result = my_predbat.exclude_dynamic_io_slots(rates) @@ -84,43 +83,32 @@ def run_exclude_dynamic_io_slots_tests(my_predbat): print(" ERROR: expected rates unchanged with empty io_adjusted, got {}".format(result.get(840))) failed = True - print("Test 6: multiple dynamic minutes are all restored, a mixed-in fixed-window minute is not") - my_predbat.trust_future_dynamic_iog_slots = False + print("Test 6: a mix of trusted, untrusted and fixed-window minutes are each handled independently") + my_predbat.trusted_dynamic_minutes = {870} # only this one is trusted my_predbat.io_adjusted = {840: True, 870: True, 120: True} rates = {840: 4.0, 870: 4.0, 120: 4.0} result = my_predbat.exclude_dynamic_io_slots(rates) - if result.get(840) != 30.0 or result.get(870) != 30.0: - print(" ERROR: expected both dynamic minutes restored, got {} and {}".format(result.get(840), result.get(870))) + if result.get(840) != 30.0: + print(" ERROR: expected untrusted dynamic minute 840 restored, got {}".format(result.get(840))) + failed = True + if result.get(870) != 4.0: + print(" ERROR: expected trusted dynamic minute 870 untouched, got {}".format(result.get(870))) failed = True if result.get(120) != 4.0: print(" ERROR: expected the fixed-window minute untouched, got {}".format(result.get(120))) failed = True - if 840 in my_predbat.io_adjusted or 870 in my_predbat.io_adjusted: - print(" ERROR: expected both dynamic minutes cleared from io_adjusted") + if 840 in my_predbat.io_adjusted: + print(" ERROR: expected untrusted minute 840 cleared from io_adjusted") + failed = True + if 870 not in my_predbat.io_adjusted: + print(" ERROR: expected trusted minute 870 to remain in io_adjusted") failed = True if 120 not in my_predbat.io_adjusted: print(" ERROR: expected the fixed-window minute to remain in io_adjusted") failed = True - - # Test 7 (#4516 follow-up): a dynamic io_adjusted minute already underway or in the past - # (minutes_now=600, so current_block=600) is left untouched regardless of the switch - - # today_cost()'s actual-spend figures need genuine historical rates, not ones retroactively - # excluded once we know the dispatch already happened. - print("Test 7: a past/current dynamic io_adjusted minute is left untouched regardless of the switch") - my_predbat.trust_future_dynamic_iog_slots = False - my_predbat.io_adjusted = {570: True} # 09:30 - before minutes_now (10:00), outside the fixed window - rates = {570: 4.0} - result = my_predbat.exclude_dynamic_io_slots(rates) - if result.get(570) != 4.0: - print(" ERROR: expected past minute 570 untouched at 4.0, got {}".format(result.get(570))) - failed = True - if 570 not in my_predbat.io_adjusted: - print(" ERROR: expected past minute 570 to remain marked io_adjusted, was cleared") - failed = True finally: - my_predbat.trust_future_dynamic_iog_slots = saved_trust_dynamic my_predbat.io_adjusted = saved_io_adjusted - my_predbat.minutes_now = saved_minutes_now + my_predbat.trusted_dynamic_minutes = saved_trusted_dynamic_minutes if failed: print("\n**** exclude_dynamic_io_slots tests: FAILED ****") diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index 831f6dfe4..6f868d161 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -8,13 +8,22 @@ # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init -from datetime import timedelta +from datetime import datetime, timedelta +import pytz from tests.test_infra import reset_rates +UTC = pytz.UTC -def run_rate_add_io_slots_test(testname, my_predbat, slots, octopus_slot_low_rate, octopus_slot_max, expected_rates, expected_slots_per_day=None): + +def run_rate_add_io_slots_test(testname, my_predbat, slots, octopus_slot_low_rate, octopus_slot_max, expected_rates, expected_slots_per_day=None, confirmed=True): """ Run a single test for rate_add_io_slots + + confirmed controls whether the passed-in slots are tagged _confirmed (as fetch_sensor_data_cars() + tags a completed_dispatches entry - #4516) or not (as a planned_dispatches entry). Defaults True + so tests 1-17, which predate #4516 and are about cap/dedup/low_rate mechanics rather than + confirmation gating, don't need to know about it - tests 18+ pass confirmed=False explicitly + where that's the point being tested. """ failed = False print("**** Running Test: rate_add_io_slots {} ****".format(testname)) @@ -30,7 +39,8 @@ def run_rate_add_io_slots_test(testname, my_predbat, slots, octopus_slot_low_rat rates[minute] = 10.0 # Run the function - result_rates = my_predbat.rate_add_io_slots(0, rates, slots) + tagged_slots = [dict(slot, _confirmed=confirmed) for slot in slots] + result_rates = my_predbat.rate_add_io_slots(0, rates, tagged_slots) # Check that expected rates were applied for minute, expected_rate in expected_rates.items(): @@ -50,10 +60,19 @@ def run_rate_add_io_slots_tests(my_predbat): failed = 0 TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" - now_utc = my_predbat.now_utc - now_utc = now_utc.replace(minute=0, second=0, microsecond=0, hour=10) - my_predbat.minutes_now = int((now_utc - my_predbat.midnight_utc).total_seconds() / 60) - midnight_utc = my_predbat.midnight_utc + + # Fully self-contained, fixed time setup (not derived from the shared my_predbat fixture's + # ambient now_utc/midnight_utc) - another test running earlier in the suite (e.g. + # multi_car_iog) can leave now_utc/minutes_now inconsistent with midnight_utc, which silently + # shifts current_block in rate_add_io_slots() and breaks the "started" tests (22-24) that + # depend on minutes_now landing exactly on a specific 30-min block. + saved_now_utc = my_predbat.now_utc + saved_midnight_utc = my_predbat.midnight_utc + saved_minutes_now = my_predbat.minutes_now + midnight_utc = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) + my_predbat.midnight_utc = midnight_utc + my_predbat.now_utc = midnight_utc + timedelta(hours=10) + my_predbat.minutes_now = 10 * 60 # Save original forecast_minutes and extend it for multi-day tests original_forecast_minutes = my_predbat.forecast_minutes @@ -64,10 +83,10 @@ def run_rate_add_io_slots_tests(my_predbat): my_predbat.rate_min_base = 4 # Tests 1-17 predate #4516's trust_future_dynamic_iog_slots gate and use daytime slots - # throughout to exercise the cap/dedup/low_rate mechanics specifically - not the new window - # gate, which tests 18-20 cover explicitly. Default to trusting dynamic slots here so tests - # 1-17 keep testing exactly what they were designed to test. - my_predbat.trust_future_dynamic_iog_slots = True + # throughout to exercise the cap/dedup/low_rate mechanics specifically - not the confirmation + # gate, which tests 18+ cover explicitly. "completed" + the helper's confirmed=True default + # trusts every slot these tests use, so they keep testing exactly what they were designed to. + my_predbat.trust_future_dynamic_iog_slots = "completed" # Test 1: Simple single slot within limit print("\n**** Test 1: Single 30-min slot ****") @@ -351,51 +370,77 @@ def run_rate_add_io_slots_tests(my_predbat): expected_rates_17[minute] = 4.0 failed |= run_rate_add_io_slots_test("test17_dup_does_not_waste_cap", my_predbat, slots_17, True, 2, expected_rates_17) - # Tests 18-20 (#4516): trust_future_dynamic_iog_slots - a dynamic (out-of-window) daytime - # dispatch slot is still Octopus's own provisional/revisable plan and can be moved or rescinded - # before it occurs, so by default it must not be treated as guaranteed cheap for the house - # battery. The fixed 23:30-05:30 window is guaranteed cheap by the tariff itself and is never - # affected regardless of the switch. + # Tests 18+ (#4516): trust_future_dynamic_iog_slots - a dynamic (out-of-window) daytime dispatch + # slot is still Octopus's own provisional/revisable plan and can be moved or rescinded before it + # occurs. Trust is graduated by source (confirmed via completed_dispatches/car_charging_now), not + # by clock time - a slot merely reaching its scheduled start time is not evidence anything + # actually happened, only that it was due to. saved_trust_dynamic = my_predbat.trust_future_dynamic_iog_slots + saved_car_charging_now = list(my_predbat.car_charging_now) - print("\n**** Test 18: Dynamic daytime slot is not low rate by default ****") - my_predbat.trust_future_dynamic_iog_slots = False + print("\n**** Test 18: 'none' - an unconfirmed dynamic slot is never trusted ****") + my_predbat.trust_future_dynamic_iog_slots = "none" slot_start_18 = midnight_utc + timedelta(hours=14) # 14:00 - well outside the fixed window slot_end_18 = slot_start_18 + timedelta(minutes=30) slots_18 = [{"start": slot_start_18.strftime(TIME_FORMAT), "end": slot_end_18.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}] expected_rates_18 = {minute: 10.0 for minute in range(840, 870)} # left at the normal rate - failed |= run_rate_add_io_slots_test("test18_dynamic_slot_not_trusted_by_default", my_predbat, slots_18, True, 12, expected_rates_18) + failed |= run_rate_add_io_slots_test("test18_none_never_trusts_dynamic_slot", my_predbat, slots_18, True, 12, expected_rates_18, confirmed=False) - print("\n**** Test 19: Fixed 23:30-05:30 window slot stays low rate regardless of the switch ****") - my_predbat.trust_future_dynamic_iog_slots = False + print("\n**** Test 19: Fixed 23:30-05:30 window slot stays low rate at every trust level ****") + my_predbat.trust_future_dynamic_iog_slots = "none" slot_start_19 = midnight_utc + timedelta(hours=2) # 02:00 - well inside 23:30-05:30 slot_end_19 = slot_start_19 + timedelta(minutes=30) slots_19 = [{"start": slot_start_19.strftime(TIME_FORMAT), "end": slot_end_19.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}] expected_rates_19 = {minute: 4.0 for minute in range(120, 150)} - failed |= run_rate_add_io_slots_test("test19_fixed_window_always_trusted", my_predbat, slots_19, True, 12, expected_rates_19) + failed |= run_rate_add_io_slots_test("test19_fixed_window_always_trusted", my_predbat, slots_19, True, 12, expected_rates_19, confirmed=False) - print("\n**** Test 20: Switch on restores unconditional trust of dynamic slots ****") - my_predbat.trust_future_dynamic_iog_slots = True + print("\n**** Test 20: 'completed' - a genuinely confirmed dynamic slot is trusted ****") + my_predbat.trust_future_dynamic_iog_slots = "completed" expected_rates_20 = {minute: 4.0 for minute in range(840, 870)} - failed |= run_rate_add_io_slots_test("test20_switch_on_trusts_dynamic_slots", my_predbat, slots_18, True, 12, expected_rates_20) - - # Test 21 (#4516 follow-up): a dynamic slot already underway or in the past is trusted - # regardless of the switch - its rescission risk has already resolved one way or the other, - # and today_cost()'s actual-spend figures (house and car) need genuine historical rates or a - # real dynamic dispatch that already happened today would wrongly report as full price. - print("\n**** Test 21: A past/current dynamic slot is trusted regardless of the switch ****") - my_predbat.trust_future_dynamic_iog_slots = False + failed |= run_rate_add_io_slots_test("test20_completed_trusts_confirmed_slot", my_predbat, slots_18, True, 12, expected_rates_20, confirmed=True) + + print("\n**** Test 21: 'completed' - a still-unconfirmed slot is NOT trusted even once its start time has passed ****") + # Directly disproves clock time alone as evidence: this slot's scheduled start (09:00) is well + # before minutes_now (10:00), but it was never confirmed, so it must stay untrusted. + my_predbat.trust_future_dynamic_iog_slots = "completed" slot_start_21 = midnight_utc + timedelta(hours=9) # 09:00 - before minutes_now (10:00), outside the fixed window slot_end_21 = slot_start_21 + timedelta(minutes=30) slots_21 = [{"start": slot_start_21.strftime(TIME_FORMAT), "end": slot_end_21.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "unknown", "location": "AT_HOME"}] - expected_rates_21 = {minute: 4.0 for minute in range(540, 570)} - failed |= run_rate_add_io_slots_test("test21_past_dynamic_slot_always_trusted", my_predbat, slots_21, True, 12, expected_rates_21) + expected_rates_21 = {minute: 10.0 for minute in range(540, 570)} + failed |= run_rate_add_io_slots_test("test21_completed_does_not_trust_by_clock_time_alone", my_predbat, slots_21, True, 12, expected_rates_21, confirmed=False) + + print("\n**** Test 22: 'started' - an unconfirmed slot is trusted for the current block when car_charging_now is true ****") + my_predbat.trust_future_dynamic_iog_slots = "started" + my_predbat.car_charging_now = [True] + slot_start_22 = midnight_utc + timedelta(hours=10) # 10:00 - the current settlement period (minutes_now=600) + slot_end_22 = slot_start_22 + timedelta(minutes=30) + slots_22 = [{"start": slot_start_22.strftime(TIME_FORMAT), "end": slot_end_22.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "unknown", "location": "AT_HOME"}] + expected_rates_22 = {minute: 4.0 for minute in range(600, 630)} + failed |= run_rate_add_io_slots_test("test22_started_trusts_current_block_with_car_charging_now", my_predbat, slots_22, True, 12, expected_rates_22, confirmed=False) + + print("\n**** Test 23: 'started' - car_charging_now does not corroborate a different (non-current) block ****") + my_predbat.trust_future_dynamic_iog_slots = "started" + my_predbat.car_charging_now = [True] + expected_rates_23 = {minute: 10.0 for minute in range(540, 570)} # slots_21 is 09:00, not the current 10:00 block + failed |= run_rate_add_io_slots_test("test23_started_car_charging_now_only_corroborates_current_block", my_predbat, slots_21, True, 12, expected_rates_23, confirmed=False) + + print("\n**** Test 24: 'started' without car_charging_now true degrades to 'completed' behaviour ****") + my_predbat.trust_future_dynamic_iog_slots = "started" + my_predbat.car_charging_now = [False] + expected_rates_24 = {minute: 10.0 for minute in range(600, 630)} # slots_22, but car isn't charging now this time + failed |= run_rate_add_io_slots_test("test24_started_without_car_charging_now_behaves_like_completed", my_predbat, slots_22, True, 12, expected_rates_24, confirmed=False) my_predbat.trust_future_dynamic_iog_slots = saved_trust_dynamic + my_predbat.car_charging_now = saved_car_charging_now # Restore original forecast_minutes my_predbat.forecast_minutes = original_forecast_minutes + # Restore original time state + my_predbat.now_utc = saved_now_utc + my_predbat.midnight_utc = saved_midnight_utc + my_predbat.minutes_now = saved_minutes_now + if failed: print("\n**** rate_add_io_slots tests: FAILED ****") else: From cafd06d4fabc277e5f7c9d4bdfdd82cefa30eb29 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sat, 15 Aug 2026 13:18:17 +0100 Subject: [PATCH 07/11] docs(car-charging): update trust_future_dynamic_iog_slots for the 3-way select (#4516) The switch became a none/completed/started select. Documents the started level's dependence on car_charging_now and the fallback-to-completed warning behaviour, and clarifies that none of the levels trust a slot on clock time alone - only the fixed window is unconditionally trusted. Co-Authored-By: Claude Sonnet 5 --- docs/car-charging.md | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/docs/car-charging.md b/docs/car-charging.md index 91a4c95a8..660bbedd3 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -353,13 +353,15 @@ 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_trust_future_dynamic_iog_slots** (*expert mode*) (default value is Off) controls whether a future daytime Intelligent dispatch slot (outside the fixed 23:30-05:30 off-peak window) is treated as a guaranteed cheap rate for the **house battery** plan. +- **select.predbat_trust_future_dynamic_iog_slots** (*expert mode*) (default value is `none`) controls whether a future daytime Intelligent dispatch slot (outside the fixed 23:30-05:30 off-peak window) is treated as a guaranteed cheap rate for the **house battery** plan. A daytime dispatch is still Octopus's own provisional plan until it actually happens - it can be moved or withdrawn beforehand, which could otherwise lead Predbat to make an irreversible decision (e.g. an early force-export) in anticipation of a cheap recharge that never occurs. -With the switch Off (the default), only the fixed 23:30-05:30 window and dispatches that have already started or completed are trusted as cheap for battery planning purposes; a still-provisional future daytime slot is left at the normal rate instead. -A slot is only ever held back while it's still in the future and unconfirmed - the moment it starts (or if it's already in the past), it's trusted regardless of this switch. -This matters beyond the plan itself: Predbat's "today's actual cost so far" figures (for both the house and, separately, your car's own charging cost) are calculated from these same rates against what was genuinely imported today, so a dispatch that has already happened is always reflected accurately - this switch only ever withholds trust from a slot that hasn't happened yet, never rewrites what already did. -This does not affect car charging: future dispatch slots are still used in full to forecast when your EV will charge, regardless of this switch - only whether they're trusted as a cheap rate for the house battery is affected. -Turn this On to restore the previous behaviour, where every dispatch slot Octopus reports is trusted as cheap for the house battery the moment it's known, whether provisional or confirmed. +Note that a slot's start time passing is not itself confirmation - Octopus can still revoke a dispatch that has technically started but where the car never actually drew power, so none of these levels trust a slot on clock time alone: + - `none` - only the fixed 23:30-05:30 window is trusted as cheap for battery planning; every daytime dispatch is left at the normal rate until Octopus reports it as completed. + - `completed` - also trusts a daytime dispatch once Octopus reports it as a completed (metered) dispatch, i.e. it genuinely happened. + - `started` - also trusts the current 30-minute block of a still-provisional dispatch if **car_charging_now** reports the car is actually charging right now - waiting for Octopus to report the slot as `completed` can be too slow to still be useful for that block's own planning decision. This level needs **car_charging_now** configured in `apps.yaml`; if it isn't set, Predbat logs a warning and behaves the same as `completed`. +The fixed overnight window is always trusted regardless of this setting. +This matters beyond the plan itself: Predbat's "today's actual cost so far" figures (for both the house and, separately, your car's own charging cost) are calculated from these same rates against what was genuinely imported today, so a dispatch that has already happened is always reflected accurately - this setting only ever withholds trust from a slot that hasn't genuinely happened yet, never rewrites what already did. +This does not affect car charging: future dispatch slots are still used in full to forecast when your EV will charge, regardless of this setting - only whether they're trusted as a cheap rate for the house battery is affected. - Let the Octopus app control when your car charges. From 8efdf55ce2d4fd81e6cd1132cb5cb2db0b0d18c3 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sun, 16 Aug 2026 17:57:14 +0100 Subject: [PATCH 08/11] fix(octopus): don't treat a live car_charging_now reading as a confirmed dispatch (#4516) add_now_to_octopus_slot() tagged its synthetic slot _confirmed=True unconditionally, on the reasoning that a live "car is charging now" read is a real-time confirmed draw. In practice car_charging_now is a single sensor read with no debounce - a charger's readiness sensor can blip on/off for reasons unrelated to a genuine charging session - so this let one noisy reading get trusted as a cheap house import rate under the "completed" and "none" trust levels too, both of which exist specifically to require more than a live guess before trusting a dynamic slot. Confirmed live while dogfooding: a flapping car_charging_now signal caused repeated synthetic slots, each briefly making rate_add_io_slots() write an assumed-cheap rate into the house's own import rate array, independent of any genuine Octopus dispatch - not just the discharge-hold oscillation this same flapping also causes downstream in execute.py, but the house battery opportunistically charging on a fabricated cheap-rate signal. Leave the tag False and let rate_add_io_slots()'s existing "started" level re-check car_charging_now live for the current settlement block only - that's the level that deliberately accepts a live reading as confirmation, not every level. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/octopus.py | 10 +++++++++- apps/predbat/tests/test_add_now_to_octopus_slot.py | 9 +++++++++ 2 files changed, 18 insertions(+), 1 deletion(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 0c4cdfac9..c19f4c4c1 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2358,7 +2358,15 @@ def add_now_to_octopus_slot(self, car_n, octopus_slots, now_utc): slot["end"] = slot_end_date.strftime(TIME_FORMAT) slot["source"] = "car_charging_now" slot["kwh"] = self.car_charging_rate[car_n] * 30 / 60 # Scale to 30 minute slot - slot["_confirmed"] = True # Real-time confirmed draw, not a still-revisable Octopus plan + # Not genuinely confirmed - car_charging_now is a single live sensor read, not a settled + # Octopus dispatch record, and can be noisy (a charger's readiness sensor can blip on/off + # for reasons unrelated to a real charging session). Tagging this True unconditionally + # let a single spurious reading get trusted as a cheap house import rate under "completed" + # and "none" trust levels too - both of which exist specifically to require more than a + # live guess before trusting a dynamic slot. Leave it False and let rate_add_io_slots()'s + # own "started" level re-check car_charging_now live (current settlement block only) - + # that's the level that deliberately accepts this trade-off, not every level. + slot["_confirmed"] = False octopus_slots.append(slot) self.log("Octopus: Car is charging now - added new IO slot {}".format(slot)) return octopus_slots diff --git a/apps/predbat/tests/test_add_now_to_octopus_slot.py b/apps/predbat/tests/test_add_now_to_octopus_slot.py index d0ef4df0d..e0ab34273 100644 --- a/apps/predbat/tests/test_add_now_to_octopus_slot.py +++ b/apps/predbat/tests/test_add_now_to_octopus_slot.py @@ -66,6 +66,15 @@ def test_add_now_to_octopus_slot(my_predbat): print("ERROR: Expected end {}, got {}".format(expected_end.strftime("%Y-%m-%dT%H:%M:%S%z"), slot["end"])) failed = True + # Regression check: a live car_charging_now reading is not the same as a genuinely settled + # Octopus dispatch - it must stay untrusted at the "completed"/"none" trust levels (only + # "started" deliberately re-checks it live for the current block). Tagging this True + # unconditionally let one noisy sensor reading get trusted as a cheap house import rate + # regardless of the user's chosen trust level. + if slot["_confirmed"] is not False: + print("ERROR: Expected _confirmed to be False (not a settled dispatch), got {}".format(slot["_confirmed"])) + failed = True + if not failed: print("Test 1 passed - single car slot added correctly") From d4597806a07aa40b3e458feb0f6d4603fc2c0cf3 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sun, 16 Aug 2026 20:57:43 +0100 Subject: [PATCH 09/11] feat(octopus): add a 'planned' trust level, restoring pre-#4516 behaviour as an opt-in (#4516) Requested on #4528: some users prefer the house battery to plan ahead of a still-provisional daytime dispatch (e.g. exporting in anticipation of it) over waiting for any confirmation, and are comfortable with the plan occasionally acting on a slot Octopus later moves or withdraws - exactly the behaviour Predbat had before trust_future_dynamic_iog_slots existed. Adds it as a fourth, explicit option alongside none/completed/started rather than reverting to unconditional trust for everyone: trusts every dynamic slot the moment it's planned, no confirmation required. Still excluded from car_charging_now-sourced synthetic slots, which stay _confirmed=False regardless of this setting (fixed separately) - "planned" widens trust for genuine Octopus-reported dispatches, not for a single live sensor reading. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/config.py | 2 +- apps/predbat/octopus.py | 8 +++++++- apps/predbat/tests/test_rate_add_io_slots.py | 5 +++++ docs/car-charging.md | 1 + 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index bd9f9b040..3afc5ac6a 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -907,7 +907,7 @@ "name": "trust_future_dynamic_iog_slots", "friendly_name": "When to trust a dynamic (daytime) Intelligent dispatch slot as cheap for the house battery", "type": "select", - "options": ["none", "completed", "started"], + "options": ["none", "completed", "started", "planned"], "default": "none", "enable": "expert_mode", }, diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index c19f4c4c1..3eae118d1 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2823,10 +2823,16 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # right now, without waiting for Octopus's own completed record to catch up. # A slot merely having reached its scheduled start time is deliberately NOT # enough on its own - the clock passing a boundary is not evidence the car - # actually started, only that it was due to. + # actually started, only that it was due to. "planned" restores the pre-#4516 + # behaviour of trusting every dynamic slot unconditionally, including one + # Octopus has only provisionally scheduled and could still move or withdraw - + # an explicit opt back into that risk for anyone who prefers Predbat to plan + # ahead of a daytime slot rather than wait for any confirmation at all. in_fixed_window = self.minute_in_iog_fixed_window(slot_start) if in_fixed_window: trusted = True + elif trust_future_dynamic_iog_slots == "planned": + trusted = True elif trust_future_dynamic_iog_slots == "completed": trusted = confirmed elif trust_future_dynamic_iog_slots == "started": diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index 6f868d161..2ec1add74 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -430,6 +430,11 @@ def run_rate_add_io_slots_tests(my_predbat): expected_rates_24 = {minute: 10.0 for minute in range(600, 630)} # slots_22, but car isn't charging now this time failed |= run_rate_add_io_slots_test("test24_started_without_car_charging_now_behaves_like_completed", my_predbat, slots_22, True, 12, expected_rates_24, confirmed=False) + print("\n**** Test 25: 'planned' - trusts a dynamic slot unconditionally, even unconfirmed (old, pre-#4516 behaviour restored as an explicit opt-in) ****") + my_predbat.trust_future_dynamic_iog_slots = "planned" + expected_rates_25 = {minute: 4.0 for minute in range(840, 870)} + failed |= run_rate_add_io_slots_test("test25_planned_trusts_unconfirmed_slot", my_predbat, slots_18, True, 12, expected_rates_25, confirmed=False) + my_predbat.trust_future_dynamic_iog_slots = saved_trust_dynamic my_predbat.car_charging_now = saved_car_charging_now diff --git a/docs/car-charging.md b/docs/car-charging.md index 660bbedd3..c3ebf0834 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -359,6 +359,7 @@ Note that a slot's start time passing is not itself confirmation - Octopus can s - `none` - only the fixed 23:30-05:30 window is trusted as cheap for battery planning; every daytime dispatch is left at the normal rate until Octopus reports it as completed. - `completed` - also trusts a daytime dispatch once Octopus reports it as a completed (metered) dispatch, i.e. it genuinely happened. - `started` - also trusts the current 30-minute block of a still-provisional dispatch if **car_charging_now** reports the car is actually charging right now - waiting for Octopus to report the slot as `completed` can be too slow to still be useful for that block's own planning decision. This level needs **car_charging_now** configured in `apps.yaml`; if it isn't set, Predbat logs a warning and behaves the same as `completed`. + - `planned` - trusts every daytime dispatch the moment Octopus plans it, with no confirmation at all - the behaviour Predbat used before this setting existed. Choose this if you'd rather the house battery plan ahead of a provisional slot (e.g. export in anticipation of it) than wait for any confirmation, and are comfortable with the plan occasionally acting on a slot Octopus later moves or withdraws. The fixed overnight window is always trusted regardless of this setting. This matters beyond the plan itself: Predbat's "today's actual cost so far" figures (for both the house and, separately, your car's own charging cost) are calculated from these same rates against what was genuinely imported today, so a dispatch that has already happened is always reflected accurately - this setting only ever withholds trust from a slot that hasn't genuinely happened yet, never rewrites what already did. This does not affect car charging: future dispatch slots are still used in full to forecast when your EV will charge, regardless of this setting - only whether they're trusted as a cheap rate for the house battery is affected. From ddf1c7f10a67fca22410d85f8646b2390096b509 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Tue, 18 Aug 2026 21:27:27 +0100 Subject: [PATCH 10/11] fix(octopus): default trust_future_dynamic_iog_slots to 'planned' Requested on #4528: the "planned" level added in 5ee2e9c5 restores Predbat's pre-#4516 unconditional-trust behaviour, but only as an opt-in - the default stayed "none", which changed behaviour for existing users the moment they updated. Trefor asked for the default to remain as-is, with the new, more cautious levels available as an explicit choice for anyone who wants them. No code change needed beyond the default itself - "planned" already does exactly this (octopus.py:2818, unconditional trusted=True with no confirmed check), added specifically to match the old behaviour. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/config.py | 2 +- docs/car-charging.md | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 3afc5ac6a..5f8ea1a2b 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -908,7 +908,7 @@ "friendly_name": "When to trust a dynamic (daytime) Intelligent dispatch slot as cheap for the house battery", "type": "select", "options": ["none", "completed", "started", "planned"], - "default": "none", + "default": "planned", "enable": "expert_mode", }, { diff --git a/docs/car-charging.md b/docs/car-charging.md index c3ebf0834..d65e4d893 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -353,13 +353,13 @@ 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 -- **select.predbat_trust_future_dynamic_iog_slots** (*expert mode*) (default value is `none`) controls whether a future daytime Intelligent dispatch slot (outside the fixed 23:30-05:30 off-peak window) is treated as a guaranteed cheap rate for the **house battery** plan. -A daytime dispatch is still Octopus's own provisional plan until it actually happens - it can be moved or withdrawn beforehand, which could otherwise lead Predbat to make an irreversible decision (e.g. an early force-export) in anticipation of a cheap recharge that never occurs. +- **select.predbat_trust_future_dynamic_iog_slots** (*expert mode*) (default value is `planned`) controls whether a future daytime Intelligent dispatch slot (outside the fixed 23:30-05:30 off-peak window) is treated as a guaranteed cheap rate for the **house battery** plan. +A daytime dispatch is still Octopus's own provisional plan until it actually happens - it can be moved or withdrawn beforehand, which could otherwise lead Predbat to make an irreversible decision (e.g. an early force-export) in anticipation of a cheap recharge that never occurs. The stricter levels below exist for anyone who has been caught out by this; the default matches Predbat's long-standing behaviour so nobody's plan changes unless they opt in. Note that a slot's start time passing is not itself confirmation - Octopus can still revoke a dispatch that has technically started but where the car never actually drew power, so none of these levels trust a slot on clock time alone: + - `planned` (default) - trusts every daytime dispatch the moment Octopus plans it, with no confirmation at all - the behaviour Predbat has always used. Choose this if you'd rather the house battery plan ahead of a provisional slot (e.g. export in anticipation of it) than wait for any confirmation, and are comfortable with the plan occasionally acting on a slot Octopus later moves or withdraws. - `none` - only the fixed 23:30-05:30 window is trusted as cheap for battery planning; every daytime dispatch is left at the normal rate until Octopus reports it as completed. - `completed` - also trusts a daytime dispatch once Octopus reports it as a completed (metered) dispatch, i.e. it genuinely happened. - `started` - also trusts the current 30-minute block of a still-provisional dispatch if **car_charging_now** reports the car is actually charging right now - waiting for Octopus to report the slot as `completed` can be too slow to still be useful for that block's own planning decision. This level needs **car_charging_now** configured in `apps.yaml`; if it isn't set, Predbat logs a warning and behaves the same as `completed`. - - `planned` - trusts every daytime dispatch the moment Octopus plans it, with no confirmation at all - the behaviour Predbat used before this setting existed. Choose this if you'd rather the house battery plan ahead of a provisional slot (e.g. export in anticipation of it) than wait for any confirmation, and are comfortable with the plan occasionally acting on a slot Octopus later moves or withdraws. The fixed overnight window is always trusted regardless of this setting. This matters beyond the plan itself: Predbat's "today's actual cost so far" figures (for both the house and, separately, your car's own charging cost) are calculated from these same rates against what was genuinely imported today, so a dispatch that has already happened is always reflected accurately - this setting only ever withholds trust from a slot that hasn't genuinely happened yet, never rewrites what already did. This does not affect car charging: future dispatch slots are still used in full to forecast when your EV will charge, regardless of this setting - only whether they're trusted as a cheap rate for the house battery is affected. From 23f0bf4f558e8330fbf0f5249a2d40b1b511b4c5 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Fri, 28 Aug 2026 11:25:50 +0100 Subject: [PATCH 11/11] fix(#4516): address review on the IOG dynamic-slot trust gate - exclude_dynamic_io_slots() only touches minutes from now onwards. Trust is a claim about a future dispatch Octopus could still move; an elapsed minute's io_adjusted marker records the rate actually charged, and rewriting it to rate_max_base inflated today_cost()'s reported import cost (output.py reads self.rate_import for elapsed minutes) without changing the plan. - exclude_dynamic_io_slots() is a no-op at trust level "planned", which is documented as restoring the pre-#4516 behaviour. It previously still stripped an io_adjusted minute with no matching octopus_slots entry, which rate_add_io_slots() never visits and so never records as trusted. - rate_add_io_slots() records a minute in trusted_dynamic_minutes only once the slot has survived the octopus_slot_max daily cap, not merely the trust test. A capped slot gets no discount here, so the feed-side discount must still be strippable rather than surviving by the back door. - Corrected two stale comments claiming the _confirmed flag can represent a live car_charging_now draw - add_now_to_octopus_slot() deliberately sets it False. - Tests: save/restore trust_future_dynamic_iog_slots (previously captured after this module had already overwritten it), trusted_dynamic_minutes and rate_max_base; clear the per-cycle trusted_dynamic_minutes accumulator per case; new cases for the cap/trust interaction, fixed-window minutes, past minutes and the "planned" no-op. Co-Authored-By: Claude Opus 5 --- apps/predbat/octopus.py | 40 +++++++++++++---- .../tests/test_exclude_dynamic_io_slots.py | 45 +++++++++++++++++++ apps/predbat/tests/test_rate_add_io_slots.py | 42 +++++++++++++++-- 3 files changed, 115 insertions(+), 12 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 3eae118d1..554e4446c 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2526,10 +2526,11 @@ def decode_octopus_slot(self, car_n, slot, raw=False): source = slot.get("source", "") location = slot.get("location", "") - # Whether this slot is genuinely confirmed (completed_dispatches, or a real-time - # car_charging_now draw) versus still Octopus's own provisional plan (planned_dispatches) - - # see fetch_sensor_data_cars() where this is tagged. Defaults False for anything untagged - # (e.g. the action_config service-call path, which only ever returns planned slots). + # Whether this slot is genuinely confirmed (completed_dispatches) versus still Octopus's own + # provisional plan (planned_dispatches) - see fetch_sensor_data_cars() where this is tagged. + # Defaults False for anything untagged (e.g. the action_config service-call path, which only + # ever returns planned slots, and add_now_to_octopus_slot()'s live car_charging_now slot, + # which is deliberately left unconfirmed - see the comment there). confirmed = slot.get("_confirmed", False) start_minutes = minutes_to_time(start, self.midnight_utc) @@ -2817,8 +2818,8 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # # For a dynamic slot, trust is graduated by trust_future_dynamic_iog_slots: # "none" never trusts one; "completed" also trusts one genuinely confirmed - # (completed_dispatches, or a real-time car_charging_now draw - see - # decode_octopus_slot()); "started" additionally trusts the *current* + # by Octopus's own completed_dispatches record (see decode_octopus_slot()); + # "started" additionally trusts the *current* # settlement period the moment car_charging_now shows the car drawing power # right now, without waiting for Octopus's own completed record to catch up. # A slot merely having reached its scheduled start time is deliberately NOT @@ -2840,9 +2841,6 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): else: # "none" trusted = False - if trusted and not in_fixed_window: - self.trusted_dynamic_minutes.add(minute) - # At the start of each 30-min slot, decide if we can add it if minute % 30 == 0: if trusted and slots_per_day[day_offset] < octopus_slot_max: @@ -2856,6 +2854,14 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): if slot_start in slots_added_set: rates[minute] = assumed_price + # Record the minute as trusted only once the slot has actually survived the + # octopus_slot_max daily cap, not merely the trust test above. A trusted slot + # beyond the cap gets no discount here, so exclude_dynamic_io_slots() must + # still be free to strip the same minute's feed-side (io_adjusted) discount - + # otherwise a capped slot would keep a cheap rate by the back door. + if not in_fixed_window and slot_start in slots_added_set: + self.trusted_dynamic_minutes.add(minute) + if minute % 30 == 0 and start_minutes > -24 * 60: self.log( "Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}, confirmed {}, trusted {}".format( @@ -2893,11 +2899,27 @@ def exclude_dynamic_io_slots(self, rates): self.trusted_dynamic_minutes - the set rate_add_io_slots() itself just built while deciding which dynamic minutes it trusted, across all cars - rather than re-deriving trust here, so the two functions can never disagree about what counts as trusted. + + Only minutes from now onwards are touched. Trust is a statement about a *future* dispatch + slot that Octopus could still move or withdraw; a minute already in the past isn't a + prediction at all, and its io_adjusted marker records the rate the tariff actually charged. + Rewriting those to rate_max_base would have inflated today_cost()'s reported import cost + (output.py reads self.rate_import for elapsed minutes) without changing the plan, since + planning never looks behind self.minutes_now. """ if not self.io_adjusted: return rates + # "planned" is documented as restoring the pre-#4516 behaviour of trusting every dynamic + # slot, so this whole mechanism has to be a no-op there - including for an io_adjusted + # minute with no matching octopus_slots entry, which rate_add_io_slots() never visits and + # so never records in trusted_dynamic_minutes. + if self.trust_future_dynamic_iog_slots == "planned": + return rates + for minute in list(self.io_adjusted.keys()): + if minute < self.minutes_now: + continue if self.io_adjusted[minute] and not self.minute_in_iog_fixed_window(minute) and minute not in self.trusted_dynamic_minutes: rates[minute] = self.rate_max_base del self.io_adjusted[minute] diff --git a/apps/predbat/tests/test_exclude_dynamic_io_slots.py b/apps/predbat/tests/test_exclude_dynamic_io_slots.py index 4c7a5e4e9..959455cd0 100644 --- a/apps/predbat/tests/test_exclude_dynamic_io_slots.py +++ b/apps/predbat/tests/test_exclude_dynamic_io_slots.py @@ -26,7 +26,15 @@ def run_exclude_dynamic_io_slots_tests(my_predbat): saved_io_adjusted = dict(my_predbat.io_adjusted) saved_trusted_dynamic_minutes = set(my_predbat.trusted_dynamic_minutes) + saved_rate_max_base = my_predbat.rate_max_base + saved_trust = my_predbat.trust_future_dynamic_iog_slots + saved_minutes_now = my_predbat.minutes_now + my_predbat.rate_max_base = 30.0 + # A trust level that actually engages the mechanism - it is deliberately a no-op at "planned" + # (tested separately below) - and a "now" early enough that every minute under test is future. + my_predbat.trust_future_dynamic_iog_slots = "none" + my_predbat.minutes_now = 0 try: print("Test 1: a dynamic io_adjusted minute not in trusted_dynamic_minutes is restored and cleared") @@ -106,9 +114,46 @@ def run_exclude_dynamic_io_slots_tests(my_predbat): if 120 not in my_predbat.io_adjusted: print(" ERROR: expected the fixed-window minute to remain in io_adjusted") failed = True + + print("Test 7: an elapsed (past) untrusted dynamic minute is left untouched - it records what was actually charged") + my_predbat.minutes_now = 900 + my_predbat.trusted_dynamic_minutes = set() + my_predbat.io_adjusted = {840: True, 960: True} + rates = {840: 4.0, 960: 4.0} + result = my_predbat.exclude_dynamic_io_slots(rates) + if result.get(840) != 4.0: + print(" ERROR: expected past minute 840 untouched at 4.0, got {}".format(result.get(840))) + failed = True + if 840 not in my_predbat.io_adjusted: + print(" ERROR: expected past minute 840 to remain marked io_adjusted, was cleared") + failed = True + if result.get(960) != 30.0: + print(" ERROR: expected future minute 960 restored to rate_max_base 30.0, got {}".format(result.get(960))) + failed = True + if 960 in my_predbat.io_adjusted: + print(" ERROR: expected future minute 960 cleared from io_adjusted, still present") + failed = True + my_predbat.minutes_now = 0 + + print("Test 8: at trust level 'planned' the whole mechanism is a no-op, even for an untrusted minute") + my_predbat.trust_future_dynamic_iog_slots = "planned" + my_predbat.trusted_dynamic_minutes = set() + my_predbat.io_adjusted = {840: True} + rates = {840: 4.0} + result = my_predbat.exclude_dynamic_io_slots(rates) + if result.get(840) != 4.0: + print(" ERROR: expected minute 840 untouched at 'planned', got {}".format(result.get(840))) + failed = True + if 840 not in my_predbat.io_adjusted: + print(" ERROR: expected minute 840 to remain marked io_adjusted at 'planned', was cleared") + failed = True + my_predbat.trust_future_dynamic_iog_slots = "none" finally: my_predbat.io_adjusted = saved_io_adjusted my_predbat.trusted_dynamic_minutes = saved_trusted_dynamic_minutes + my_predbat.rate_max_base = saved_rate_max_base + my_predbat.trust_future_dynamic_iog_slots = saved_trust + my_predbat.minutes_now = saved_minutes_now if failed: print("\n**** exclude_dynamic_io_slots tests: FAILED ****") diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index 2ec1add74..b4b5861c7 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -38,7 +38,10 @@ def run_rate_add_io_slots_test(testname, my_predbat, slots, octopus_slot_low_rat for minute in range(-96 * 60, max(my_predbat.forecast_minutes, 3 * 24 * 60)): rates[minute] = 10.0 - # Run the function + # Run the function. trusted_dynamic_minutes is a per-cycle accumulator that fetch.py clears at + # the start of each rate rebuild - clear it here too so one test's trusted minutes can't leak + # into the next one's assertions on it. + my_predbat.trusted_dynamic_minutes = set() tagged_slots = [dict(slot, _confirmed=confirmed) for slot in slots] result_rates = my_predbat.rate_add_io_slots(0, rates, tagged_slots) @@ -69,6 +72,11 @@ def run_rate_add_io_slots_tests(my_predbat): saved_now_utc = my_predbat.now_utc saved_midnight_utc = my_predbat.midnight_utc saved_minutes_now = my_predbat.minutes_now + # Captured before the "completed" default is applied below, so the restore at the end puts back + # the fixture's own value rather than this module's. + saved_trust_dynamic = my_predbat.trust_future_dynamic_iog_slots + saved_trusted_dynamic_minutes = set(my_predbat.trusted_dynamic_minutes) + saved_car_charging_now = list(my_predbat.car_charging_now) midnight_utc = datetime(2026, 1, 1, 0, 0, 0, tzinfo=UTC) my_predbat.midnight_utc = midnight_utc my_predbat.now_utc = midnight_utc + timedelta(hours=10) @@ -375,8 +383,6 @@ def run_rate_add_io_slots_tests(my_predbat): # occurs. Trust is graduated by source (confirmed via completed_dispatches/car_charging_now), not # by clock time - a slot merely reaching its scheduled start time is not evidence anything # actually happened, only that it was due to. - saved_trust_dynamic = my_predbat.trust_future_dynamic_iog_slots - saved_car_charging_now = list(my_predbat.car_charging_now) print("\n**** Test 18: 'none' - an unconfirmed dynamic slot is never trusted ****") my_predbat.trust_future_dynamic_iog_slots = "none" @@ -435,7 +441,37 @@ def run_rate_add_io_slots_tests(my_predbat): expected_rates_25 = {minute: 4.0 for minute in range(840, 870)} failed |= run_rate_add_io_slots_test("test25_planned_trusts_unconfirmed_slot", my_predbat, slots_18, True, 12, expected_rates_25, confirmed=False) + print("\n**** Test 26: a trusted dynamic slot rejected by the octopus_slot_max cap is not recorded as trusted ****") + # trusted_dynamic_minutes is what exclude_dynamic_io_slots() consults to decide whether to strip + # the feed-side (io_adjusted) discount for a minute. A slot that passed the trust test but was + # then rejected by the daily cap gets no discount from rate_add_io_slots(), so it must not be + # recorded as trusted either - otherwise the capped slot would keep a cheap rate by the back door. + my_predbat.trust_future_dynamic_iog_slots = "planned" + slots_26 = [] + for i in range(2): + slot_start_26 = midnight_utc + timedelta(hours=14, minutes=i * 30) # 14:00 and 14:30, both dynamic + slot_end_26 = slot_start_26 + timedelta(minutes=30) + slots_26.append({"start": slot_start_26.strftime(TIME_FORMAT), "end": slot_end_26.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}) + # Cap of 1 slot per day: 14:00-14:30 is added, 14:30-15:00 is rejected by the cap + expected_rates_26 = {minute: 4.0 for minute in range(840, 870)} + expected_rates_26.update({minute: 10.0 for minute in range(870, 900)}) + failed |= run_rate_add_io_slots_test("test26_capped_slot_is_not_recorded_as_trusted", my_predbat, slots_26, True, 1, expected_rates_26, confirmed=False) + if not all(minute in my_predbat.trusted_dynamic_minutes for minute in range(840, 870)): + print("ERROR: expected the added slot's minutes 840-869 to be recorded in trusted_dynamic_minutes") + failed = True + if any(minute in my_predbat.trusted_dynamic_minutes for minute in range(870, 900)): + print("ERROR: expected the cap-rejected slot's minutes 870-899 to be absent from trusted_dynamic_minutes") + failed = True + + print("\n**** Test 27: a fixed-window slot is never recorded as trusted (it isn't dynamic) ****") + my_predbat.trust_future_dynamic_iog_slots = "none" + failed |= run_rate_add_io_slots_test("test27_fixed_window_not_recorded_as_trusted", my_predbat, slots_19, True, 12, expected_rates_19, confirmed=False) + if any(minute in my_predbat.trusted_dynamic_minutes for minute in range(120, 150)): + print("ERROR: expected fixed-window minutes 120-149 to be absent from trusted_dynamic_minutes") + failed = True + my_predbat.trust_future_dynamic_iog_slots = saved_trust_dynamic + my_predbat.trusted_dynamic_minutes = saved_trusted_dynamic_minutes my_predbat.car_charging_now = saved_car_charging_now # Restore original forecast_minutes