diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 24b85a570..5f8ea1a2b 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -903,6 +903,14 @@ "default": False, "enable": "expert_mode", }, + { + "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", "planned"], + "default": "planned", + "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 7cbf78a0a..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)] @@ -981,6 +985,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) @@ -1194,6 +1202,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,16 +1216,32 @@ 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) - # Completed and planned slots - merge from all cars + # #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. 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") @@ -2509,6 +2534,17 @@ 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") + 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 2e4951711..554e4446c 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2358,6 +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 + # 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 @@ -2488,6 +2497,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 @@ -2501,6 +2526,12 @@ 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) 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) end_minutes = minutes_to_time(end, self.midnight_utc) @@ -2512,7 +2543,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 @@ -2526,7 +2557,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: @@ -2542,7 +2573,46 @@ 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): + """ + 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): """ @@ -2561,7 +2631,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. @@ -2684,6 +2754,11 @@ 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 - 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) # Period 0 = noon today to 11:59 tomorrow, Period -1 = noon yesterday to 11:59 today, etc. @@ -2697,7 +2772,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"): @@ -2736,9 +2811,39 @@ 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). + # + # For a dynamic slot, trust is graduated by trust_future_dynamic_iog_slots: + # "none" never trusts one; "completed" also trusts one genuinely confirmed + # 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 + # enough on its own - the clock passing a boundary is not evidence the car + # 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": + 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 + # 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 @@ -2749,10 +2854,18 @@ 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 {}".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 {}, 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 ) ) @@ -2763,6 +2876,56 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): return rates + def exclude_dynamic_io_slots(self, rates): + """ + 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, + 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. 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. + + 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] + + 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..ac146a098 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -469,6 +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 = "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_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") 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/tests/test_exclude_dynamic_io_slots.py b/apps/predbat/tests/test_exclude_dynamic_io_slots.py new file mode 100644 index 000000000..959455cd0 --- /dev/null +++ b/apps/predbat/tests/test_exclude_dynamic_io_slots.py @@ -0,0 +1,163 @@ +# ----------------------------------------------------------------------------- +# 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 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. +""" + + +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_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") + 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) + 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 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) + 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: 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 trusted minute 840 untouched, got {}".format(result.get(840))) + failed = True + if 840 not in my_predbat.io_adjusted: + 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.trusted_dynamic_minutes = set() + 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.trusted_dynamic_minutes = set() + 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: 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: + 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: + 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 + + 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 ****") + 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..b4b5861c7 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)) @@ -29,8 +38,12 @@ 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 - result_rates = my_predbat.rate_add_io_slots(0, rates, slots) + # 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) # Check that expected rates were applied for minute, expected_rate in expected_rates.items(): @@ -50,10 +63,24 @@ 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 + # 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) + my_predbat.minutes_now = 10 * 60 # Save original forecast_minutes and extend it for multi-day tests original_forecast_minutes = my_predbat.forecast_minutes @@ -63,6 +90,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 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 ****") slot_start = midnight_utc + timedelta(hours=2) @@ -345,9 +378,110 @@ 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+ (#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. + + 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_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 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, confirmed=False) + + 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_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: 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) + + 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) + + 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 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: diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 918471906..194dd49f3 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -174,6 +174,8 @@ 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_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 @@ -421,6 +423,8 @@ 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), + ("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..d65e4d893 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. @@ -352,6 +353,17 @@ Predbat will still assume all Octopus charging slots are low rates even if some - The switch **switch.predbat_octopus_intelligent_ignore_unplugged** (*expert mode*) (default value is Off) can be used to prevent Predbat from assuming the car will be charging or that future extra low-rate slots apply when the car is unplugged. This will only work correctly if **car_charging_planned** is set correctly in `apps.yaml` to detect your car being plugged in +- **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`. +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. ### Predbat-led charging