From 2c0004c36518ee0012ee22a0510763c48bcf38e6 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Sun, 4 Oct 2026 14:26:20 +0100 Subject: [PATCH 1/5] feat(octopus): don't trust Intelligent slots the car won't need (#4482) With octopus_intelligent_consider_full on, load_octopus_slots() already ends each car's slots where it reaches its limit, so the car is modelled not to draw in the rest - but every dispatch minute still kept its cheap rate, and the house battery planned around slots Octopus only bills cheap when the car charges in them. octopus_surplus_minutes() works out the future dispatch minutes no car still needs (rounded out to whole half hours, shared across cars, outside the fixed 23:30-05:30 window), and both rate paths now withhold them the way they already do for a cancelled car: rate_add_io_slots() doesn't stamp them, and dynamic_load_car_strip_feed_rates() removes a discount the rate feed already delivered. Elapsed minutes keep their rate so today's cost stays true. Behaviour is unchanged with consider_full off. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/fetch.py | 1 + apps/predbat/octopus.py | 62 +++++- apps/predbat/predbat.py | 1 + apps/predbat/tests/test_iog_car_need_gate.py | 189 +++++++++++++++++++ apps/predbat/unit_test.py | 2 + docs/car-charging.md | 3 +- 6 files changed, 249 insertions(+), 9 deletions(-) create mode 100644 apps/predbat/tests/test_iog_car_need_gate.py diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 5c58a0a54..742d308b4 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1241,6 +1241,7 @@ def fetch_sensor_data(self, save=True): if import_rates: self.rate_scan(import_rates, print=False) self.rate_import_base, self.rate_min_base, self.rate_max_base = self.rate_base_min_max(import_rates) + self.octopus_surplus = self.octopus_surplus_minutes() import_rates = self.dynamic_load_car_strip_feed_rates(import_rates) import_rates, self.rate_import_replicated = self.rate_replicate(import_rates, self.io_adjusted, is_import=True) self.rate_import_no_io = import_rates.copy() diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 5ab97a54e..aba949c02 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3729,6 +3729,9 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): if strip_from is not None and minute >= strip_from: continue # Car is not charging, its dispatch is not trusted as cheap + if minute in self.octopus_surplus: + continue # No car needs this dispatch minute, see octopus_surplus_minutes() + if minute in saved_slots: continue # Already applied a low rate slot to this minute, skip else: @@ -3788,6 +3791,50 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): return rates + def in_iog_fixed_window(self, minute): + """ + Whether minute falls in the fixed 23:30-05:30 Intelligent Go window, cheap by tariff rather than by + dispatch, so no dispatch decision ever changes its rate. + """ + window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"] + window_start = window["start"][0] * 60 + window["start"][1] + window_end = window["end"][0] * 60 + window["end"][1] + minute_of_day = minute % (24 * 60) + return minute_of_day >= window_start or minute_of_day < window_end + + def octopus_surplus_minutes(self): + """ + Future Octopus Intelligent dispatch minutes no car needs, when octopus_intelligent_consider_full is on. + + load_octopus_slots() already ends each car's slots where the car reaches its limit, so a dispatch + minute none of the cars' remaining slots cover is one the car won't draw in, and Octopus only bills + a dispatch cheap when the car charges in it (#4482). Its cheap rate is withheld from the house + battery's plan the same way a cancelled car's is (see dynamic_load_car_strip_feed_rates()). + + Rounded out to whole 30 minute rate periods, from now onwards and outside the fixed window. Rates + are shared, so a minute any car still needs is kept. A car not modelled from its Octopus slots this + cycle (e.g. ignored while unplugged) is left alone: there is no need to compare its dispatches with. + """ + if not self.octopus_intelligent_consider_full: + return set() + dispatch_minutes = set() + needed_minutes = set() + for car_n in range(min(self.num_cars, len(self.octopus_slots), len(self.car_charging_slots))): + car_slots = self.car_charging_slots[car_n] + if not any(slot.get("octopus", False) for slot in car_slots): + continue + for slot in self.octopus_slots[car_n]: + start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True) + if start_minutes == end_minutes: + continue + start_minutes, end_minutes = round_out_to_period(start_minutes, end_minutes) + dispatch_minutes.update(range(max(start_minutes, self.minutes_now), end_minutes)) + for slot in car_slots: + if slot.get("kwh", 0) > 0: + start_minutes, end_minutes = round_out_to_period(slot["start"], slot["end"]) + needed_minutes.update(range(start_minutes, end_minutes)) + return {minute for minute in dispatch_minutes - needed_minutes if not self.in_iog_fixed_window(minute)} + def dynamic_load_car_strip_feed_rates(self, rates): """ Remove an Octopus Intelligent discount the rate feed itself delivered for a dispatch of a car that @@ -3805,7 +3852,7 @@ def dynamic_load_car_strip_feed_rates(self, rates): (see dynamic_load_car_strip_from()), whatever another cancelled car's dispatch covers. """ cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_effective.get(car_n, False)] - if not cancelled_cars or not self.io_adjusted: + if (not cancelled_cars and not self.octopus_surplus) or not self.io_adjusted: self.dynamic_load_car_stripped = 0 return rates @@ -3827,23 +3874,22 @@ def dynamic_load_car_strip_feed_rates(self, rates): trusted_minutes.update(range(start_minutes, min(end_minutes, strip_from))) cancelled_minutes.update(range(max(start_minutes, strip_from), end_minutes)) - window = OCTOPUS_NIGHT_RATE_WINDOWS["iog"] - window_start = window["start"][0] * 60 + window["start"][1] - window_end = window["end"][0] * 60 + window["end"][1] stripped = 0 - for minute in sorted(cancelled_minutes - trusted_minutes): + stripped_surplus = 0 + for minute in sorted((cancelled_minutes - trusted_minutes) | self.octopus_surplus): if not self.io_adjusted.get(minute, False): continue - minute_of_day = minute % (24 * 60) - if minute_of_day >= window_start or minute_of_day < window_end: + if self.in_iog_fixed_window(minute): continue rates[minute] = self.rate_max_base del self.io_adjusted[minute] stripped += 1 + if minute in self.octopus_surplus: + stripped_surplus += 1 # The feed is re-read every cycle, so the same minutes are stripped again each time - log only # when that changes, not every 5 minutes for the length of a cancellation if stripped and stripped != self.dynamic_load_car_stripped: - self.log("Octopus Intelligent: removed the dispatch rate from {} minutes of cars {} which are not charging".format(stripped, cancelled_cars)) + self.log("Octopus Intelligent: removed the dispatch rate from {} minutes - {} of cars {} which are not charging, {} no car needs".format(stripped, stripped - stripped_surplus, cancelled_cars, stripped_surplus)) self.dynamic_load_car_stripped = stripped return rates diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index c5696eede..5f230ccc1 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -560,6 +560,7 @@ def reset(self): self.dynamic_load_car_sensors = {} self.dynamic_load_car_effective = {} self.dynamic_load_car_stripped = 0 + self.octopus_surplus = set() self.battery_capacity_nominal = False self.battery_scaling_auto = False self.releases = {} diff --git a/apps/predbat/tests/test_iog_car_need_gate.py b/apps/predbat/tests/test_iog_car_need_gate.py new file mode 100644 index 000000000..4f99c9d82 --- /dev/null +++ b/apps/predbat/tests/test_iog_car_need_gate.py @@ -0,0 +1,189 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +"""Tests for the Intelligent Octopus car-need gate (#4482). + +With octopus_intelligent_consider_full on, load_octopus_slots() ends a car's slots where it reaches its +limit. A future dispatch minute no car still needs is one the car won't draw in, and Octopus only bills a +dispatch cheap when the car charges, so its cheap rate is withheld from the house battery's plan - both +the discount rate_add_io_slots() would add and one the rate feed already delivered. +""" + +import copy +from datetime import timedelta + +from tests.test_infra import reset_rates + +TIME_FORMAT = "%Y-%m-%dT%H:%M:%S%z" + +_ATTRS = [ + "minutes_now", + "forecast_minutes", + "octopus_intelligent_consider_full", + "octopus_slots", + "car_charging_slots", + "car_charging_soc", + "car_charging_limit", + "car_charging_loss", + "car_charging_rate", + "num_cars", + "io_adjusted", + "octopus_surplus", + "dynamic_load_car_effective", + "dynamic_load_car_stripped", + "rate_min", + "rate_min_base", + "rate_max_base", + "rate_import", + "rate_export", +] + + +def _slot(my_predbat, start_hour, end_hour, kwh): + """A raw Octopus dispatch from start_hour to end_hour today, as the integration reports it.""" + start = my_predbat.midnight_utc + timedelta(hours=start_hour) + end = my_predbat.midnight_utc + timedelta(hours=end_hour) + return {"start": start.strftime(TIME_FORMAT), "end": end.strftime(TIME_FORMAT), "charge_in_kwh": kwh, "source": "smart-charge", "location": "AT_HOME"} + + +def _day_rates(minute_from, minute_to, rate): + """A flat rate table.""" + return {minute: rate for minute in range(minute_from, minute_to)} + + +def _expect(name, condition, detail): + """Print and return True on failure.""" + if not condition: + print(" ERROR: {}: {}".format(name, detail)) + return True + return False + + +def run_surplus_minutes_tests(my_predbat): + """octopus_surplus_minutes() on hand-built capped slots.""" + failed = False + my_predbat.minutes_now = 10 * 60 + my_predbat.num_cars = 1 + my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)]] + needed = [{"start": 13 * 60, "end": 13 * 60 + 20, "kwh": 5.0, "octopus": True}, {"start": 13 * 60 + 20, "end": 15 * 60, "kwh": 0.0, "octopus": True}] + my_predbat.car_charging_slots = [needed] + + my_predbat.octopus_intelligent_consider_full = False + failed |= _expect("consider_full off", my_predbat.octopus_surplus_minutes() == set(), "the gate must be off") + + my_predbat.octopus_intelligent_consider_full = True + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("partial need rounds out", surplus == set(range(13 * 60 + 30, 15 * 60)), "expected 13:30-15:00, the rest of the half hour the car needs stays cheap, got {}-{}".format(min(surplus or [0]), max(surplus or [0]))) + + # Fixed 23:30-05:30 window is cheap by tariff, never touched + my_predbat.octopus_slots = [[_slot(my_predbat, 23, 24.5, 20.0)]] + my_predbat.car_charging_slots = [[{"start": 23 * 60, "end": 24 * 60 + 30, "kwh": 0.0, "octopus": True}]] + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("fixed window untouched", surplus == set(range(23 * 60, 23 * 60 + 30)), "only 23:00-23:30 is dispatch-only, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + + # Elapsed minutes are history - only from now onwards + my_predbat.octopus_slots = [[_slot(my_predbat, 9, 11, 20.0)]] + my_predbat.car_charging_slots = [[{"start": 9 * 60, "end": 11 * 60, "kwh": 0.0, "octopus": True}]] + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("elapsed untouched", surplus == set(range(10 * 60, 11 * 60)), "expected 10:00-11:00 only") + + # Rates are shared: a minute another car still needs stays cheap + my_predbat.num_cars = 2 + my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)], [_slot(my_predbat, 14, 14.5, 5.0)]] + my_predbat.car_charging_slots = [needed, [{"start": 14 * 60, "end": 14 * 60 + 30, "kwh": 5.0, "octopus": True}]] + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("other car's need kept", not (surplus & set(range(14 * 60, 14 * 60 + 30))) and (13 * 60 + 30) in surplus, "14:00-14:30 is needed by car 1") + + # A car not modelled from its Octopus slots (e.g. ignored while unplugged) is left alone + my_predbat.num_cars = 1 + my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)]] + my_predbat.car_charging_slots = [[]] + failed |= _expect("car not modelled is ignored", my_predbat.octopus_surplus_minutes() == set(), "no capped slots to compare with") + return failed + + +def _end_to_end(my_predbat, consider_full, feed): + """ + Car at 95 of 100 kWh with a 13:00-15:00 dispatch Octopus sized at 20 kWh: the car needs 5 kWh, the + first half hour. Runs the real capping, surplus and both rate paths, returns (rates, io_adjusted). + With feed, the rate feed has already discounted and flagged the whole dispatch. + """ + my_predbat.minutes_now = 10 * 60 + my_predbat.forecast_minutes = 3 * 24 * 60 + my_predbat.num_cars = 1 + my_predbat.args["octopus_slot_low_rate"] = True + my_predbat.args["octopus_slot_max"] = 12 + reset_rates(my_predbat, 30, 5) + my_predbat.rate_min = 4 + my_predbat.rate_min_base = 4 + my_predbat.rate_max_base = 30 + my_predbat.car_charging_soc = [95.0] + my_predbat.car_charging_limit = [100.0] + my_predbat.car_charging_loss = 1.0 + my_predbat.car_charging_rate = [10.0] + my_predbat.dynamic_load_car_effective = {} + my_predbat.octopus_intelligent_consider_full = consider_full + my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)]] + my_predbat.car_charging_slots = [my_predbat.load_octopus_slots(0, my_predbat.octopus_slots[0], consider_full)] + + rates = _day_rates(-96 * 60, 3 * 24 * 60, 30.0) + my_predbat.io_adjusted = {} + if feed: + for minute in range(13 * 60, 15 * 60): + rates[minute] = 4.0 + my_predbat.io_adjusted[minute] = True + my_predbat.octopus_surplus = my_predbat.octopus_surplus_minutes() + rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) + rates = my_predbat.rate_add_io_slots(0, rates, my_predbat.octopus_slots[0]) + return rates, my_predbat.io_adjusted, my_predbat.car_charging_slots[0] + + +def run_end_to_end_tests(my_predbat): + """The capping, the surplus and both rate paths together.""" + failed = False + needed = range(13 * 60, 13 * 60 + 30) + surplus = range(13 * 60 + 30, 15 * 60) + + for feed in (False, True): + path = "feed" if feed else "overlay" + rates, io_adjusted, car_slots = _end_to_end(my_predbat, True, feed) + print(" {} path, capped car slots {}".format(path, [(slot["start"], slot["end"], slot["kwh"]) for slot in car_slots])) + failed |= _expect("{}: needed half hour cheap".format(path), all(rates[minute] == 4.0 for minute in needed), "13:00-13:30 should be 4p") + failed |= _expect("{}: needed half hour flagged".format(path), all(io_adjusted.get(minute, False) for minute in needed), "13:00-13:30 should stay io_adjusted") + failed |= _expect("{}: surplus at peak".format(path), all(rates[minute] == 30.0 for minute in surplus), "13:30-15:00 should be 30p, got {}".format(sorted({rates[minute] for minute in surplus}))) + failed |= _expect("{}: surplus unflagged".format(path), not any(io_adjusted.get(minute, False) for minute in surplus), "13:30-15:00 should not be io_adjusted") + + rates, io_adjusted, _ = _end_to_end(my_predbat, False, False) + failed |= _expect("consider_full off: whole dispatch cheap", all(rates[minute] == 4.0 for minute in range(13 * 60, 15 * 60)), "behaviour must be unchanged with the switch off") + return failed + + +def run_iog_car_need_gate_tests(my_predbat): + """Run the car-need gate tests, returns True on failure.""" + print("**** Running IOG car-need gate tests ****") + saved = {attr: copy.deepcopy(getattr(my_predbat, attr)) for attr in _ATTRS if hasattr(my_predbat, attr)} + saved_args = {key: my_predbat.args[key] for key in ("octopus_slot_low_rate", "octopus_slot_max") if key in my_predbat.args} + failed = False + try: + failed |= run_surplus_minutes_tests(my_predbat) + failed |= run_end_to_end_tests(my_predbat) + finally: + for attr, value in saved.items(): + setattr(my_predbat, attr, value) + for key in ("octopus_slot_low_rate", "octopus_slot_max"): + if key in saved_args: + my_predbat.args[key] = saved_args[key] + else: + my_predbat.args.pop(key, None) + if failed: + print("**** IOG car-need gate tests FAILED ****") + else: + print("**** IOG car-need gate tests passed ****") + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index f076cb42d..f077f2aae 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -235,6 +235,7 @@ from tests.test_annual_weather import test_annual_weather, test_annual_weather_orientation_cache, test_annual_weather_window 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_car_need_gate import run_iog_car_need_gate_tests from tests.test_iog_charge_skew import run_iog_charge_skew_tests from tests.test_iog_early_slot_flat_battery import run_iog_early_slot_flat_battery_tests from tests.test_multi_car_load import run_multi_car_load_tests @@ -570,6 +571,7 @@ def main(): ("octopus_slot_max_default", run_octopus_slot_max_default_tests, "Octopus slot max auto-detection from IOG-SMB tariff code", False), ("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_car_need_gate", run_iog_car_need_gate_tests, "IOG car-need gate: no cheap rate for dispatch minutes no car needs", False), ("iog_charge_skew", run_iog_charge_skew_tests, "IOG earlier-charge skew characterisation tests", False), ("iog_early_slot_flat_battery", run_iog_early_slot_flat_battery_tests, "IOG bonus dispatch before the night window with a flat battery", False), ("multi_car_load", run_multi_car_load_tests, "Cars charging at once each add their own energy to the load", False), diff --git a/docs/car-charging.md b/docs/car-charging.md index 8cabe4852..587618852 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -483,7 +483,8 @@ It does not add an Intelligent slot or its cheap rate for a charge the Intellige - The switch **switch.predbat_octopus_intelligent_consider_full** (*expert mode*) (default is Off) when turned On will cause Predbat to predict when your car battery is full and assume no further charging will occur. This can be useful if Octopus does not know your car battery's state of charge but you have a sensor setup in Predbat (**car_charging_soc**) which does know the current charge level. -Predbat will still assume all Octopus charging slots are low rates even if some are not used by your car. +Slots your car won't need are also not trusted as low rate for the house battery: Octopus only bills a slot at the low rate when the car charges in it, so once Predbat expects the car to be full, the rest of that dispatch and any later daytime slots are planned at the normal rate. +The half hour in which the car is expected to finish stays low rate, as does any slot another car still needs and the overnight 23:30-05:30 rate, which is part of the tariff. - 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 From 1c3a836e8e8d591a58226edf389503c6c9e717d6 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 10:12:20 +0100 Subject: [PATCH 2/5] fix(octopus): a cancelled car's slots still count as needed by the car-need gate dynamic_load_car_check() cancels a car by zeroing its future slots and keeping the kWh in kwh_cancelled. The car-need gate read that zero as "the car won't need it", so with octopus_intelligent_consider_full on it stripped the cheap rate from the rest of the half hour #5229 deliberately keeps for a car seen charging in it. Cancellation decides a cancelled car's rate itself; the gate now only answers whether a slot is beyond the car's need. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/octopus.py | 4 +++- apps/predbat/tests/test_iog_car_need_gate.py | 9 +++++++++ 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index aba949c02..8834af075 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3830,7 +3830,9 @@ def octopus_surplus_minutes(self): start_minutes, end_minutes = round_out_to_period(start_minutes, end_minutes) dispatch_minutes.update(range(max(start_minutes, self.minutes_now), end_minutes)) for slot in car_slots: - if slot.get("kwh", 0) > 0: + # A cancelled car's slots are zeroed with their kWh kept in kwh_cancelled (dynamic_load_car_check()); + # cancellation decides their rate itself, so they still count as needed here + if slot.get("kwh", 0) > 0 or slot.get("kwh_cancelled", 0) > 0: start_minutes, end_minutes = round_out_to_period(slot["start"], slot["end"]) needed_minutes.update(range(start_minutes, end_minutes)) return {minute for minute in dispatch_minutes - needed_minutes if not self.in_iog_fixed_window(minute)} diff --git a/apps/predbat/tests/test_iog_car_need_gate.py b/apps/predbat/tests/test_iog_car_need_gate.py index 4f99c9d82..9df867e87 100644 --- a/apps/predbat/tests/test_iog_car_need_gate.py +++ b/apps/predbat/tests/test_iog_car_need_gate.py @@ -101,6 +101,15 @@ def run_surplus_minutes_tests(my_predbat): surplus = my_predbat.octopus_surplus_minutes() failed |= _expect("other car's need kept", not (surplus & set(range(14 * 60, 14 * 60 + 30))) and (13 * 60 + 30) in surplus, "14:00-14:30 is needed by car 1") + # A cancelled car's slots are zeroed with the kWh kept in kwh_cancelled: still needed, cancellation + # (#5229) decides their rate - including the rest of a half hour the car was seen charging in + cancelled = [{"start": 13 * 60, "end": 13 * 60 + 20, "kwh": 0, "kwh_cancelled": 5.0, "octopus": True}, {"start": 13 * 60 + 20, "end": 15 * 60, "kwh": 0, "octopus": True}] + my_predbat.num_cars = 1 + my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)]] + my_predbat.car_charging_slots = [cancelled] + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("cancelled car's need kept", surplus == set(range(13 * 60 + 30, 15 * 60)), "13:00-13:30 is still needed by the cancelled car") + # A car not modelled from its Octopus slots (e.g. ignored while unplugged) is left alone my_predbat.num_cars = 1 my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)]] From e272a1bbc8f8a92c8713542acc750fbc15944c51 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 10:50:43 +0100 Subject: [PATCH 3/5] fix(octopus): keep the current half hour out of the car-need gate The surplus started at minutes_now, which usually falls mid-period, so the rest of the current half hour lost its low rate once the car was full - even when the car charged earlier in it, which makes Octopus bill the whole period cheap. Surplus now starts at the next half-hour boundary. Clamping back to the start of the current period instead would have marked elapsed minutes as surplus too, and rate_add_io_slots() applies the skip to past minutes, misreporting today's cost. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/octopus.py | 5 ++++- apps/predbat/tests/test_iog_car_need_gate.py | 9 +++++++++ 2 files changed, 13 insertions(+), 1 deletion(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 8834af075..e133c3361 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3819,6 +3819,9 @@ def octopus_surplus_minutes(self): return set() dispatch_minutes = set() needed_minutes = set() + # The current half hour is never surplus: the car may have charged earlier in it, which makes the whole + # period cheap, and once the car is full its slots no longer show that + next_period = ((self.minutes_now + 29) // 30) * 30 for car_n in range(min(self.num_cars, len(self.octopus_slots), len(self.car_charging_slots))): car_slots = self.car_charging_slots[car_n] if not any(slot.get("octopus", False) for slot in car_slots): @@ -3828,7 +3831,7 @@ def octopus_surplus_minutes(self): if start_minutes == end_minutes: continue start_minutes, end_minutes = round_out_to_period(start_minutes, end_minutes) - dispatch_minutes.update(range(max(start_minutes, self.minutes_now), end_minutes)) + dispatch_minutes.update(range(max(start_minutes, next_period), end_minutes)) for slot in car_slots: # A cancelled car's slots are zeroed with their kWh kept in kwh_cancelled (dynamic_load_car_check()); # cancellation decides their rate itself, so they still count as needed here diff --git a/apps/predbat/tests/test_iog_car_need_gate.py b/apps/predbat/tests/test_iog_car_need_gate.py index 9df867e87..84dc60fdd 100644 --- a/apps/predbat/tests/test_iog_car_need_gate.py +++ b/apps/predbat/tests/test_iog_car_need_gate.py @@ -94,6 +94,15 @@ def run_surplus_minutes_tests(my_predbat): surplus = my_predbat.octopus_surplus_minutes() failed |= _expect("elapsed untouched", surplus == set(range(10 * 60, 11 * 60)), "expected 10:00-11:00 only") + # Mid half hour, with the car already full: the rest of the current half hour stays cheap (the car may have + # charged earlier in it), and nothing elapsed is ever touched + my_predbat.minutes_now = 14 * 60 + 10 + my_predbat.octopus_slots = [[_slot(my_predbat, 13.5, 15, 20.0)]] + my_predbat.car_charging_slots = [[{"start": 14 * 60 + 10, "end": 15 * 60, "kwh": 0.0, "octopus": True}]] + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("current half hour kept", surplus == set(range(14 * 60 + 30, 15 * 60)), "expected 14:30-15:00 only, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + my_predbat.minutes_now = 10 * 60 + # Rates are shared: a minute another car still needs stays cheap my_predbat.num_cars = 2 my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)], [_slot(my_predbat, 14, 14.5, 5.0)]] From 380117c15694a4fc62acd02291accd82bbf04cc9 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 18:25:54 +0100 Subject: [PATCH 4/5] fix(octopus): decide the car-need gate per car before sharing (#5403 review) Copilot's review of the gate found four ways it could misprice a dispatch: - The surplus started at the next half-hour boundary computed as a ceiling, which is the current boundary when the floored clock sits on one (a fetch at 14:02 reads 14:00), so the current half hour could still lose its rate. It now starts at the strictly next boundary. - A cancelled car's kwh_cancelled counted as needed for the whole slot, so it kept a full car's surplus minutes cheap though neither car would charge then. A car's need now counts only before its own cheap rate ends (dynamic_load_car_strip_from()), which still keeps the half hour GH#5316 keeps for a car seen charging in it. - A car not modelled from its Octopus slots was simply skipped, so a full car's overlapping surplus stripped its discount too. Its dispatches are now kept, up to its own strip_from. - An untrimmed dispatch in progress (octopus_intelligent_slot isn't trimmed to now, unlike the Octopus API component's) is capped from its original start, so the capped slot can end before the car finishes. The running dispatch is now left alone; dynamic load cancels it in real time once the car stops. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/octopus.py | 45 +++++---- apps/predbat/tests/test_iog_car_need_gate.py | 101 ++++++++++++++----- 2 files changed, 104 insertions(+), 42 deletions(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index e133c3361..1904ea2d0 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3804,41 +3804,48 @@ def in_iog_fixed_window(self, minute): def octopus_surplus_minutes(self): """ - Future Octopus Intelligent dispatch minutes no car needs, when octopus_intelligent_consider_full is on. + Future Octopus Intelligent dispatch minutes no car can use, when octopus_intelligent_consider_full is on. load_octopus_slots() already ends each car's slots where the car reaches its limit, so a dispatch - minute none of the cars' remaining slots cover is one the car won't draw in, and Octopus only bills - a dispatch cheap when the car charges in it (#4482). Its cheap rate is withheld from the house - battery's plan the same way a cancelled car's is (see dynamic_load_car_strip_feed_rates()). + minute no car still needs is one no car will draw in, and Octopus only bills a dispatch cheap when + the car charges in it (#4482). Its cheap rate is withheld from the house battery's plan the same way + a cancelled car's is (see dynamic_load_car_strip_feed_rates()). - Rounded out to whole 30 minute rate periods, from now onwards and outside the fixed window. Rates - are shared, so a minute any car still needs is kept. A car not modelled from its Octopus slots this - cycle (e.g. ignored while unplugged) is left alone: there is no need to compare its dispatches with. + Decided per car, then shared, as the rates are shared: a minute stays cheap if any car can still use + it before the point that car's own cheap rate ends (dynamic_load_car_strip_from()). A car can use + - its needed slots - with kWh left, or kWh kept in kwh_cancelled for a cancelled car; + - the whole of a dispatch in progress now. Its capped slots can't be relied on mid-dispatch (one from + octopus_intelligent_slot isn't trimmed to now, so its finish is worked out from its original start), + and dynamic load cancels it in real time once the car stops charging; + - every dispatch, for a car not modelled from its Octopus slots this cycle (e.g. ignored while unplugged). + + Rounded out to whole 30 minute rate periods, from the half hour after the current one (the car may have + charged earlier in it, making the whole period cheap) and outside the fixed window. """ if not self.octopus_intelligent_consider_full: return set() dispatch_minutes = set() - needed_minutes = set() - # The current half hour is never surplus: the car may have charged earlier in it, which makes the whole - # period cheap, and once the car is full its slots no longer show that - next_period = ((self.minutes_now + 29) // 30) * 30 - for car_n in range(min(self.num_cars, len(self.octopus_slots), len(self.car_charging_slots))): - car_slots = self.car_charging_slots[car_n] - if not any(slot.get("octopus", False) for slot in car_slots): - continue + usable_minutes = set() + next_period = (self.minutes_now // 30 + 1) * 30 + for car_n in range(min(self.num_cars, len(self.octopus_slots))): + car_slots = self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else [] + modelled = any(slot.get("octopus", False) for slot in car_slots) + strip_from = self.dynamic_load_car_strip_from(car_n) + usable_until = strip_from if strip_from is not None else float("inf") for slot in self.octopus_slots[car_n]: start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True) if start_minutes == end_minutes: continue + in_progress = start_minutes <= self.minutes_now < end_minutes start_minutes, end_minutes = round_out_to_period(start_minutes, end_minutes) dispatch_minutes.update(range(max(start_minutes, next_period), end_minutes)) + if in_progress or not modelled: + usable_minutes.update(range(start_minutes, min(end_minutes, usable_until))) for slot in car_slots: - # A cancelled car's slots are zeroed with their kWh kept in kwh_cancelled (dynamic_load_car_check()); - # cancellation decides their rate itself, so they still count as needed here if slot.get("kwh", 0) > 0 or slot.get("kwh_cancelled", 0) > 0: start_minutes, end_minutes = round_out_to_period(slot["start"], slot["end"]) - needed_minutes.update(range(start_minutes, end_minutes)) - return {minute for minute in dispatch_minutes - needed_minutes if not self.in_iog_fixed_window(minute)} + usable_minutes.update(range(start_minutes, min(end_minutes, usable_until))) + return {minute for minute in dispatch_minutes - usable_minutes if not self.in_iog_fixed_window(minute)} def dynamic_load_car_strip_feed_rates(self, rates): """ diff --git a/apps/predbat/tests/test_iog_car_need_gate.py b/apps/predbat/tests/test_iog_car_need_gate.py index 84dc60fdd..6a6cb3e9b 100644 --- a/apps/predbat/tests/test_iog_car_need_gate.py +++ b/apps/predbat/tests/test_iog_car_need_gate.py @@ -37,6 +37,7 @@ "io_adjusted", "octopus_surplus", "dynamic_load_car_effective", + "dynamic_load_car_confirmed", "dynamic_load_car_stripped", "rate_min", "rate_min_base", @@ -71,6 +72,8 @@ def run_surplus_minutes_tests(my_predbat): failed = False my_predbat.minutes_now = 10 * 60 my_predbat.num_cars = 1 + my_predbat.dynamic_load_car_effective = {} + my_predbat.dynamic_load_car_confirmed = {} my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)]] needed = [{"start": 13 * 60, "end": 13 * 60 + 20, "kwh": 5.0, "octopus": True}, {"start": 13 * 60 + 20, "end": 15 * 60, "kwh": 0.0, "octopus": True}] my_predbat.car_charging_slots = [needed] @@ -88,19 +91,27 @@ def run_surplus_minutes_tests(my_predbat): surplus = my_predbat.octopus_surplus_minutes() failed |= _expect("fixed window untouched", surplus == set(range(23 * 60, 23 * 60 + 30)), "only 23:00-23:30 is dispatch-only, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) - # Elapsed minutes are history - only from now onwards + # A dispatch in progress is never trimmed: its capped slots can't be relied on mid-dispatch, and dynamic + # load cancels it once the car stops. Elapsed minutes are never touched either. my_predbat.octopus_slots = [[_slot(my_predbat, 9, 11, 20.0)]] my_predbat.car_charging_slots = [[{"start": 9 * 60, "end": 11 * 60, "kwh": 0.0, "octopus": True}]] surplus = my_predbat.octopus_surplus_minutes() - failed |= _expect("elapsed untouched", surplus == set(range(10 * 60, 11 * 60)), "expected 10:00-11:00 only") + failed |= _expect("in-progress dispatch kept", surplus == set(), "expected nothing, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + + # The clock is floored to five minutes, so a fetch at 14:02 reads 14:00: the current half hour must still be + # kept, as the car may already have charged in it. A dispatch starting later in it rounds out to 14:00. + my_predbat.minutes_now = 14 * 60 + my_predbat.octopus_slots = [[_slot(my_predbat, 14.25, 15, 20.0)]] + my_predbat.car_charging_slots = [[{"start": 14 * 60 + 15, "end": 15 * 60, "kwh": 0.0, "octopus": True}]] + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("current half hour kept on the boundary", surplus == set(range(14 * 60 + 30, 15 * 60)), "expected 14:30-15:00 only, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) - # Mid half hour, with the car already full: the rest of the current half hour stays cheap (the car may have - # charged earlier in it), and nothing elapsed is ever touched + # And mid half hour my_predbat.minutes_now = 14 * 60 + 10 - my_predbat.octopus_slots = [[_slot(my_predbat, 13.5, 15, 20.0)]] - my_predbat.car_charging_slots = [[{"start": 14 * 60 + 10, "end": 15 * 60, "kwh": 0.0, "octopus": True}]] + my_predbat.octopus_slots = [[_slot(my_predbat, 14 + 20 / 60, 15, 20.0)]] + my_predbat.car_charging_slots = [[{"start": 14 * 60 + 20, "end": 15 * 60, "kwh": 0.0, "octopus": True}]] surplus = my_predbat.octopus_surplus_minutes() - failed |= _expect("current half hour kept", surplus == set(range(14 * 60 + 30, 15 * 60)), "expected 14:30-15:00 only, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + failed |= _expect("current half hour kept mid-period", surplus == set(range(14 * 60 + 30, 15 * 60)), "expected 14:30-15:00 only, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) my_predbat.minutes_now = 10 * 60 # Rates are shared: a minute another car still needs stays cheap @@ -110,46 +121,77 @@ def run_surplus_minutes_tests(my_predbat): surplus = my_predbat.octopus_surplus_minutes() failed |= _expect("other car's need kept", not (surplus & set(range(14 * 60, 14 * 60 + 30))) and (13 * 60 + 30) in surplus, "14:00-14:30 is needed by car 1") - # A cancelled car's slots are zeroed with the kWh kept in kwh_cancelled: still needed, cancellation - # (#5229) decides their rate - including the rest of a half hour the car was seen charging in - cancelled = [{"start": 13 * 60, "end": 13 * 60 + 20, "kwh": 0, "kwh_cancelled": 5.0, "octopus": True}, {"start": 13 * 60 + 20, "end": 15 * 60, "kwh": 0, "octopus": True}] + # A cancelled car's need counts only until its cheap rate ends (strip_from): its slots are zeroed with the kWh + # kept in kwh_cancelled, and it mustn't keep a minute cheap for a full car that won't charge in it either + my_predbat.dynamic_load_car_effective = {0: False, 1: True} + my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)], [_slot(my_predbat, 14, 15, 10.0)]] + my_predbat.car_charging_slots = [needed, [{"start": 14 * 60, "end": 15 * 60, "kwh": 0, "kwh_cancelled": 10.0, "octopus": True}]] + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("cancelled car's need not shared", surplus == set(range(13 * 60 + 30, 15 * 60)), "car 1 is cancelled, so 14:00-15:00 is nobody's, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + + # ...but the rest of a half hour a cancelled car was seen charging in stays cheap (GH#5316), and no more my_predbat.num_cars = 1 + my_predbat.minutes_now = 13 * 60 + 10 + my_predbat.dynamic_load_car_effective = {0: True} + my_predbat.dynamic_load_car_confirmed = {0: my_predbat.midnight_utc + timedelta(hours=13, minutes=30)} my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)]] - my_predbat.car_charging_slots = [cancelled] + my_predbat.car_charging_slots = [[{"start": 13 * 60, "end": 13 * 60 + 20, "kwh": 0, "kwh_cancelled": 5.0, "octopus": True}, {"start": 13 * 60 + 20, "end": 15 * 60, "kwh": 0, "octopus": True}]] surplus = my_predbat.octopus_surplus_minutes() - failed |= _expect("cancelled car's need kept", surplus == set(range(13 * 60 + 30, 15 * 60)), "13:00-13:30 is still needed by the cancelled car") + failed |= _expect("cancelled car kept only to strip_from", surplus == set(range(13 * 60 + 30, 15 * 60)), "expected 13:30-15:00, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + my_predbat.minutes_now = 10 * 60 + my_predbat.dynamic_load_car_effective = {} + my_predbat.dynamic_load_car_confirmed = {} - # A car not modelled from its Octopus slots (e.g. ignored while unplugged) is left alone - my_predbat.num_cars = 1 + # A car not modelled from its Octopus slots (e.g. ignored while unplugged) keeps its dispatches cheap... my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)]] my_predbat.car_charging_slots = [[]] - failed |= _expect("car not modelled is ignored", my_predbat.octopus_surplus_minutes() == set(), "no capped slots to compare with") + failed |= _expect("car not modelled is kept", my_predbat.octopus_surplus_minutes() == set(), "no capped slots to compare with") + + # ...including where a full car's dispatch overlaps it, unless that car is cancelled + my_predbat.num_cars = 2 + my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)], [_slot(my_predbat, 14, 15, 10.0)]] + my_predbat.car_charging_slots = [needed, []] + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("dispatch of a car not modelled kept", surplus == set(range(13 * 60 + 30, 14 * 60)), "expected 13:30-14:00, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + my_predbat.dynamic_load_car_effective = {1: True} + surplus = my_predbat.octopus_surplus_minutes() + failed |= _expect("cancelled car not modelled is not kept", surplus == set(range(13 * 60 + 30, 15 * 60)), "expected 13:30-15:00, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + my_predbat.dynamic_load_car_effective = {} + my_predbat.num_cars = 1 return failed -def _end_to_end(my_predbat, consider_full, feed): +def _end_to_end(my_predbat, consider_full, feed, minutes_now=10 * 60, cancelled_second_car=False): """ Car at 95 of 100 kWh with a 13:00-15:00 dispatch Octopus sized at 20 kWh: the car needs 5 kWh, the first half hour. Runs the real capping, surplus and both rate paths, returns (rates, io_adjusted). - With feed, the rate feed has already discounted and flagged the whole dispatch. + With feed, the rate feed has already discounted and flagged the whole dispatch. With + cancelled_second_car, a second car whose 14:00-15:00 dispatch dynamic load has cancelled shares it. + The dispatch is left untrimmed, as octopus_intelligent_slot reports it. """ - my_predbat.minutes_now = 10 * 60 + my_predbat.minutes_now = minutes_now my_predbat.forecast_minutes = 3 * 24 * 60 - my_predbat.num_cars = 1 + my_predbat.num_cars = 2 if cancelled_second_car else 1 my_predbat.args["octopus_slot_low_rate"] = True my_predbat.args["octopus_slot_max"] = 12 reset_rates(my_predbat, 30, 5) my_predbat.rate_min = 4 my_predbat.rate_min_base = 4 my_predbat.rate_max_base = 30 - my_predbat.car_charging_soc = [95.0] - my_predbat.car_charging_limit = [100.0] + my_predbat.car_charging_soc = [95.0, 0.0] + my_predbat.car_charging_limit = [100.0, 100.0] my_predbat.car_charging_loss = 1.0 - my_predbat.car_charging_rate = [10.0] + my_predbat.car_charging_rate = [10.0, 10.0] my_predbat.dynamic_load_car_effective = {} + my_predbat.dynamic_load_car_confirmed = {} my_predbat.octopus_intelligent_consider_full = consider_full my_predbat.octopus_slots = [[_slot(my_predbat, 13, 15, 20.0)]] my_predbat.car_charging_slots = [my_predbat.load_octopus_slots(0, my_predbat.octopus_slots[0], consider_full)] + if cancelled_second_car: + # As dynamic_load_car_check() leaves a cancelled car: future slots zeroed, the kWh kept + my_predbat.octopus_slots.append([_slot(my_predbat, 14, 15, 10.0)]) + my_predbat.car_charging_slots.append([{"start": 14 * 60, "end": 15 * 60, "kwh": 0, "kwh_cancelled": 10.0, "octopus": True}]) + my_predbat.dynamic_load_car_effective = {0: False, 1: True} rates = _day_rates(-96 * 60, 3 * 24 * 60, 30.0) my_predbat.io_adjusted = {} @@ -159,7 +201,8 @@ def _end_to_end(my_predbat, consider_full, feed): my_predbat.io_adjusted[minute] = True my_predbat.octopus_surplus = my_predbat.octopus_surplus_minutes() rates = my_predbat.dynamic_load_car_strip_feed_rates(rates) - rates = my_predbat.rate_add_io_slots(0, rates, my_predbat.octopus_slots[0]) + for car_n in range(my_predbat.num_cars): + rates = my_predbat.rate_add_io_slots(car_n, rates, my_predbat.octopus_slots[car_n]) return rates, my_predbat.io_adjusted, my_predbat.car_charging_slots[0] @@ -180,6 +223,18 @@ def run_end_to_end_tests(my_predbat): rates, io_adjusted, _ = _end_to_end(my_predbat, False, False) failed |= _expect("consider_full off: whole dispatch cheap", all(rates[minute] == 4.0 for minute in range(13 * 60, 15 * 60)), "behaviour must be unchanged with the switch off") + + for feed in (False, True): + path = "feed" if feed else "overlay" + # In progress at 14:10: the untrimmed dispatch's capped slot ends at 13:30, from its original start, though + # the car is still charging - the running dispatch is left alone + rates, io_adjusted, _ = _end_to_end(my_predbat, True, feed, minutes_now=14 * 60 + 10) + failed |= _expect("{}: in-progress dispatch kept".format(path), all(rates[minute] == 4.0 for minute in range(14 * 60 + 10, 15 * 60)), "14:10-15:00 should stay 4p, got {}".format(sorted({rates[minute] for minute in range(14 * 60 + 10, 15 * 60)}))) + + # A cancelled second car's need mustn't keep the full car's surplus cheap: neither will charge then + rates, io_adjusted, _ = _end_to_end(my_predbat, True, feed, cancelled_second_car=True) + failed |= _expect("{}: needed half hour still cheap with a cancelled car".format(path), all(rates[minute] == 4.0 for minute in needed), "13:00-13:30 should be 4p") + failed |= _expect("{}: cancelled car's need not shared".format(path), all(rates[minute] == 30.0 for minute in surplus), "13:30-15:00 should be 30p, got {}".format(sorted({rates[minute] for minute in surplus}))) return failed From 22c1a14bbb19432c222c3c8a14b3d4f807434a6c Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Mon, 5 Oct 2026 19:59:29 +0100 Subject: [PATCH 5/5] fix(octopus): judge a running dispatch from now, and replan when the surplus changes (#5403 review) Trefor's review of #5403: 1. A dispatch in progress was exempt from the gate for its whole length, so one long dispatch kept all its surplus cheap from the moment it started, while the same charge chunked into half-hour dispatches didn't. With consider_full on, load_octopus_slots() now caps a running dispatch's need from its remaining span and energy (as trim_started_dispatch() already does for the Octopus API component, but octopus_intelligent_slot doesn't), after the slot-cap chunking, which still counts it from its real start. The exemption is gone. 2. A change in the surplus didn't force a replan - the rates can move without the slots moving, as the car's SoC changes how many half hours it needs. fetch_sensor_data() now replans when it changes, guarded by save like dynamic_load_car_check(). Only the part of the previous surplus still ahead is compared, so time passing alone isn't a change. Co-Authored-By: Claude Opus 5.5 --- apps/predbat/fetch.py | 8 ++- apps/predbat/octopus.py | 34 +++++++-- apps/predbat/tests/test_iog_car_need_gate.py | 73 ++++++++++++++++++-- 3 files changed, 100 insertions(+), 15 deletions(-) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 742d308b4..b807f9b49 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1188,6 +1188,7 @@ def fetch_sensor_data(self, save=True): # Dynamic load: cancel the Octopus Intelligent slots of a car that is in one but not charging - # before the rates are built, so a cancelled dispatch never gets its cheap rate dynamic_load_car_changed = self.dynamic_load_car_check(save=save) + octopus_surplus_changed = False if "rates_export_octopus_url" in self.args: # Fixed URL for rate export @@ -1241,7 +1242,12 @@ def fetch_sensor_data(self, save=True): if import_rates: self.rate_scan(import_rates, print=False) self.rate_import_base, self.rate_min_base, self.rate_max_base = self.rate_base_min_max(import_rates) + previous_surplus = self.octopus_surplus self.octopus_surplus = self.octopus_surplus_minutes() + # The rates can move without the slots moving (the car's SoC changes how many half hours it needs) + if save and self.octopus_surplus_changed(previous_surplus): + self.log("Octopus Intelligent: dispatch minutes no car needs changed to {}".format(len(self.octopus_surplus))) + octopus_surplus_changed = True import_rates = self.dynamic_load_car_strip_feed_rates(import_rates) import_rates, self.rate_import_replicated = self.rate_replicate(import_rates, self.io_adjusted, is_import=True) self.rate_import_no_io = import_rates.copy() @@ -1373,7 +1379,7 @@ def fetch_sensor_data(self, save=True): else: self.load_inday_adjustment = 1.0 - force_replan = dynamic_load_car_changed + force_replan = dynamic_load_car_changed or octopus_surplus_changed # Compare on the change-detection signature, not the raw slots, so the per-cycle re-clocking # of an in-progress dispatch (start advanced to now, energy scaled to remaining time) does not # force a replan every cycle while a slot is active - only genuine slot changes do diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 1904ea2d0..ff4bc2a3a 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3623,6 +3623,14 @@ def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_ # Emission is future-only, so dispatch_billed_off_peak()'s completed-dispatch exemption # (#4946) can never apply here - a plain location test is equivalent and is kept as-is. if (end_minutes > start_minutes) and (end_minutes > self.minutes_now) and (not location or location == "AT_HOME"): + if octopus_intelligent_consider_full and start_minutes < self.minutes_now: + # A dispatch in progress: only its remaining span and energy are still to come, so cap the + # car's need from now, not from the dispatch's original start. The Octopus API component + # already trims a running dispatch (trim_started_dispatch()); octopus_intelligent_slot doesn't. + # Done after the slot-cap chunking above, which counts the dispatch from its real start. + kwh = kwh * (end_minutes - self.minutes_now) / (end_minutes - start_minutes) + kwh_original = kwh + start_minutes = self.minutes_now kwh_expected = kwh * self.car_charging_loss if octopus_intelligent_consider_full: kwh_expected = max(min(kwh_expected, limit - car_soc), 0) @@ -3802,6 +3810,21 @@ def in_iog_fixed_window(self, minute): minute_of_day = minute % (24 * 60) return minute_of_day >= window_start or minute_of_day < window_end + def octopus_surplus_from(self): + """ + The first minute the car-need gate can withhold: the half hour after the current one, as the car may + already have charged in the current one, making the whole period cheap. + """ + return (self.minutes_now // 30 + 1) * 30 + + def octopus_surplus_changed(self, previous): + """ + Whether this cycle's surplus differs from the previous cycle's, so the plan must be recomputed. Only the + part of the previous surplus still ahead is compared, so time passing alone isn't a change. + """ + start = self.octopus_surplus_from() + return {minute for minute in previous if minute >= start} != self.octopus_surplus + def octopus_surplus_minutes(self): """ Future Octopus Intelligent dispatch minutes no car can use, when octopus_intelligent_consider_full is on. @@ -3813,10 +3836,8 @@ def octopus_surplus_minutes(self): Decided per car, then shared, as the rates are shared: a minute stays cheap if any car can still use it before the point that car's own cheap rate ends (dynamic_load_car_strip_from()). A car can use - - its needed slots - with kWh left, or kWh kept in kwh_cancelled for a cancelled car; - - the whole of a dispatch in progress now. Its capped slots can't be relied on mid-dispatch (one from - octopus_intelligent_slot isn't trimmed to now, so its finish is worked out from its original start), - and dynamic load cancels it in real time once the car stops charging; + - its needed slots - with kWh left, or kWh kept in kwh_cancelled for a cancelled car. A dispatch in + progress is capped from now (see load_octopus_slots()), so this holds mid-dispatch too; - every dispatch, for a car not modelled from its Octopus slots this cycle (e.g. ignored while unplugged). Rounded out to whole 30 minute rate periods, from the half hour after the current one (the car may have @@ -3826,7 +3847,7 @@ def octopus_surplus_minutes(self): return set() dispatch_minutes = set() usable_minutes = set() - next_period = (self.minutes_now // 30 + 1) * 30 + next_period = self.octopus_surplus_from() for car_n in range(min(self.num_cars, len(self.octopus_slots))): car_slots = self.car_charging_slots[car_n] if car_n < len(self.car_charging_slots) else [] modelled = any(slot.get("octopus", False) for slot in car_slots) @@ -3836,10 +3857,9 @@ def octopus_surplus_minutes(self): start_minutes, end_minutes, _, _, _ = self.decode_octopus_slot(car_n, slot, raw=True, boundaries_only=True) if start_minutes == end_minutes: continue - in_progress = start_minutes <= self.minutes_now < end_minutes start_minutes, end_minutes = round_out_to_period(start_minutes, end_minutes) dispatch_minutes.update(range(max(start_minutes, next_period), end_minutes)) - if in_progress or not modelled: + if not modelled: usable_minutes.update(range(start_minutes, min(end_minutes, usable_until))) for slot in car_slots: if slot.get("kwh", 0) > 0 or slot.get("kwh_cancelled", 0) > 0: diff --git a/apps/predbat/tests/test_iog_car_need_gate.py b/apps/predbat/tests/test_iog_car_need_gate.py index 6a6cb3e9b..c43e9e233 100644 --- a/apps/predbat/tests/test_iog_car_need_gate.py +++ b/apps/predbat/tests/test_iog_car_need_gate.py @@ -91,12 +91,12 @@ def run_surplus_minutes_tests(my_predbat): surplus = my_predbat.octopus_surplus_minutes() failed |= _expect("fixed window untouched", surplus == set(range(23 * 60, 23 * 60 + 30)), "only 23:00-23:30 is dispatch-only, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) - # A dispatch in progress is never trimmed: its capped slots can't be relied on mid-dispatch, and dynamic - # load cancels it once the car stops. Elapsed minutes are never touched either. + # A dispatch in progress with the car already full: from the next half hour it is surplus like any other + # (its capped slots are worked out from now, see load_octopus_slots()). Elapsed minutes are never touched. my_predbat.octopus_slots = [[_slot(my_predbat, 9, 11, 20.0)]] - my_predbat.car_charging_slots = [[{"start": 9 * 60, "end": 11 * 60, "kwh": 0.0, "octopus": True}]] + my_predbat.car_charging_slots = [[{"start": 10 * 60, "end": 11 * 60, "kwh": 0.0, "octopus": True}]] surplus = my_predbat.octopus_surplus_minutes() - failed |= _expect("in-progress dispatch kept", surplus == set(), "expected nothing, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + failed |= _expect("in-progress dispatch from the next half hour", surplus == set(range(10 * 60 + 30, 11 * 60)), "expected 10:30-11:00, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) # The clock is floored to five minutes, so a fetch at 14:02 reads 14:00: the current half hour must still be # kept, as the car may already have charged in it. A dispatch starting later in it rounds out to 14:00. @@ -226,10 +226,12 @@ def run_end_to_end_tests(my_predbat): for feed in (False, True): path = "feed" if feed else "overlay" - # In progress at 14:10: the untrimmed dispatch's capped slot ends at 13:30, from its original start, though - # the car is still charging - the running dispatch is left alone + # In progress at 14:10, untrimmed as octopus_intelligent_slot reports it: 5 kWh at the remaining 10 kW + # runs to 14:40, so the car's need is capped from now and 14:30-15:00 stays cheap rates, io_adjusted, _ = _end_to_end(my_predbat, True, feed, minutes_now=14 * 60 + 10) - failed |= _expect("{}: in-progress dispatch kept".format(path), all(rates[minute] == 4.0 for minute in range(14 * 60 + 10, 15 * 60)), "14:10-15:00 should stay 4p, got {}".format(sorted({rates[minute] for minute in range(14 * 60 + 10, 15 * 60)}))) + failed |= _expect( + "{}: in-progress dispatch capped from now".format(path), all(rates[minute] == 4.0 for minute in range(14 * 60 + 10, 15 * 60)), "14:10-15:00 should stay 4p, got {}".format(sorted({rates[minute] for minute in range(14 * 60 + 10, 15 * 60)})) + ) # A cancelled second car's need mustn't keep the full car's surplus cheap: neither will charge then rates, io_adjusted, _ = _end_to_end(my_predbat, True, feed, cancelled_second_car=True) @@ -238,6 +240,61 @@ def run_end_to_end_tests(my_predbat): return failed +def _long_dispatch_surplus(my_predbat, minutes_now, chunked): + """ + #5403 review: car at 55 of 60 kWh (needs 5 kWh), consider_full on, Octopus charging 13:00-17:00 either as + one 28 kWh dispatch or as eight half-hour 3.5 kWh dispatches. Returns the surplus from the real capping. + """ + my_predbat.minutes_now = minutes_now + my_predbat.forecast_minutes = 3 * 24 * 60 + my_predbat.num_cars = 1 + my_predbat.args["octopus_slot_low_rate"] = True + my_predbat.args["octopus_slot_max"] = 12 + reset_rates(my_predbat, 30, 5) + my_predbat.rate_min = 4 + my_predbat.rate_min_base = 4 + my_predbat.rate_max_base = 30 + my_predbat.car_charging_soc = [55.0] + my_predbat.car_charging_limit = [60.0] + my_predbat.car_charging_loss = 1.0 + my_predbat.car_charging_rate = [7.0] + my_predbat.dynamic_load_car_effective = {} + my_predbat.dynamic_load_car_confirmed = {} + my_predbat.octopus_intelligent_consider_full = True + if chunked: + dispatches = [_slot(my_predbat, 13 + n / 2, 13.5 + n / 2, 3.5) for n in range(8)] + else: + dispatches = [_slot(my_predbat, 13, 17, 28.0)] + my_predbat.octopus_slots = [dispatches] + my_predbat.car_charging_slots = [my_predbat.load_octopus_slots(0, dispatches, True)] + return my_predbat.octopus_surplus_minutes() + + +def run_long_dispatch_tests(my_predbat): + """The gate must give the same answer whether or not a long dispatch has started, however Octopus chunks it.""" + failed = False + for minutes_now in (12 * 60 + 55, 13 * 60 + 5): + for chunked in (False, True): + surplus = _long_dispatch_surplus(my_predbat, minutes_now, chunked) + name = "{} at {:02d}:{:02d}".format("eight half hours" if chunked else "one dispatch", minutes_now // 60, minutes_now % 60) + failed |= _expect(name, surplus == set(range(14 * 60, 17 * 60)), "expected 14:00-17:00 withheld, got {}".format(sorted(surplus)[:1] + sorted(surplus)[-1:])) + return failed + + +def run_replan_trigger_tests(my_predbat): + """A surplus change forces a replan, but time passing alone does not.""" + failed = False + my_predbat.minutes_now = 13 * 60 + 5 + my_predbat.octopus_surplus = set(range(14 * 60, 17 * 60)) + failed |= _expect("unchanged", not my_predbat.octopus_surplus_changed(set(range(14 * 60, 17 * 60))), "same surplus is not a change") + failed |= _expect("car needs more", my_predbat.octopus_surplus_changed(set(range(15 * 60, 17 * 60))), "a different future surplus is a change") + # The cycle at 12:55 withheld 13:00-17:00; at 13:05 the current half hour is no longer withheld + my_predbat.octopus_surplus = set(range(13 * 60 + 30, 17 * 60)) + failed |= _expect("time passing", not my_predbat.octopus_surplus_changed(set(range(13 * 60, 17 * 60))), "a previous surplus that now starts in the current half hour is not a change") + failed |= _expect("gate switched on", my_predbat.octopus_surplus_changed(set()), "a new surplus is a change") + return failed + + def run_iog_car_need_gate_tests(my_predbat): """Run the car-need gate tests, returns True on failure.""" print("**** Running IOG car-need gate tests ****") @@ -247,6 +304,8 @@ def run_iog_car_need_gate_tests(my_predbat): try: failed |= run_surplus_minutes_tests(my_predbat) failed |= run_end_to_end_tests(my_predbat) + failed |= run_long_dispatch_tests(my_predbat) + failed |= run_replan_trigger_tests(my_predbat) finally: for attr, value in saved.items(): setattr(my_predbat, attr, value)