From 70829222d6eb2eae0637581dd1d9072a53d42166 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Tue, 11 Aug 2026 10:52:45 +0100 Subject: [PATCH 1/4] fix(octopus): don't rely on future IOG dispatch slots the car no longer needs For #4482: Octopus grants daytime Intelligent Go dispatch slots based on its own assumption of what the car needs, since it can't see the car's real SoC - only Predbat can, via car_charging_soc/car_charging_limit. load_octopus_slots() already caps car_charging_slots[car_n] at the car's real remaining requirement when octopus_intelligent_consider_full is on, zeroing the kwh of any slot beyond that. But rate_add_io_slots() (which decides which dispatch slots the house battery can rely on as a cheap rate) never looked at that - it still treated every granted slot as low rate regardless of whether the car's own plan says it's still needed. If Predbat had already deferred house battery charging into one of those "surplus" slots, the slot could disappear once Octopus notices the car has stopped drawing, leaving the battery undercharged with no cheap window left to make it up. Adds octopus_intelligent_limit_future_slots (expert mode, default Off, requires octopus_intelligent_consider_full to have any effect - warns at startup otherwise): a *future* out-of-window dispatch slot only counts as low rate for the house battery while it falls within a 30-min block car_charging_slots still lists a positive kwh for. A slot already underway or completed is trusted regardless (only future need is gated), and the fixed 23:30-05:30 window is never affected, since it's guaranteed cheap by the tariff itself rather than the dispatch mechanism. Test coverage: 6 new rate_add_io_slots scenarios (partial-need-vs-full settlement period, car already full, current/completed dispatch exempted, switch off restores old behaviour, fixed window unaffected) plus a fetch_config_options warning test for the consider_full dependency. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/config.py | 7 ++ apps/predbat/fetch.py | 12 +++ apps/predbat/octopus.py | 48 +++++++++- apps/predbat/predbat.py | 1 + .../tests/test_fetch_config_options.py | 50 +++++++++++ apps/predbat/tests/test_rate_add_io_slots.py | 89 +++++++++++++++++++ docs/car-charging.md | 6 +- 7 files changed, 209 insertions(+), 4 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 6028b85a9..12bb3bbbd 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -903,6 +903,13 @@ "default": False, "enable": "expert_mode", }, + { + "name": "octopus_intelligent_limit_future_slots", + "friendly_name": "Only treat future Intelligent dispatch slots as low rate while the car still needs them", + "type": "switch", + "default": False, + "enable": "expert_mode", + }, { "name": "car_charging_plan_smart", "friendly_name": "Car Charging Plan Smart", diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 4c80a452d..a53ad5e45 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -2834,6 +2834,18 @@ def fetch_config_options(self): self.octopus_intelligent_charging = self.get_arg("octopus_intelligent_charging") self.octopus_intelligent_ignore_unplugged = self.get_arg("octopus_intelligent_ignore_unplugged") self.octopus_intelligent_consider_full = self.get_arg("octopus_intelligent_consider_full") + self.octopus_intelligent_limit_future_slots = self.get_arg("octopus_intelligent_limit_future_slots") + if self.octopus_intelligent_limit_future_slots and self.octopus_intelligent_charging and not self.octopus_intelligent_consider_full: + self.log( + "Warn: switch.predbat_octopus_intelligent_limit_future_slots is On but octopus_intelligent_consider_full is Off - " + "load_octopus_slots() never zeroes out the slots beyond what the car's real SoC/limit still needs, so this switch " + "has nothing to act on and future daytime IOG slots will be treated as low rate exactly as before. Turn " + "octopus_intelligent_consider_full On too for this to have any effect." + ) + self.record_status( + "Warn: octopus_intelligent_limit_future_slots is On but octopus_intelligent_consider_full is Off - has no effect", + had_errors=True, + ) 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 c640b740c..f38c81c5d 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2820,6 +2820,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 (#4482). + """ + 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 @@ -3038,6 +3054,7 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): """ octopus_slot_low_rate = self.get_arg("octopus_slot_low_rate", True) octopus_slot_max = self.get_arg("octopus_slot_max", OCTOPUS_SLOT_MAX_DEFAULT) + limit_future_slots = self.octopus_intelligent_limit_future_slots # Track slots per 24-hour period (keyed by day offset from midday) # Period 0 = noon today to 11:59 tomorrow, Period -1 = noon yesterday to 11:59 today, etc. @@ -3047,6 +3064,24 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): slots_added_set = set() plan_interval_minutes = self.plan_interval_minutes saved_slots = set() # For logging purposes, track which slots we actually applied as low rate + current_block = (self.minutes_now // 30) * 30 + + # #4482: Octopus often grants more daytime dispatch slots than the car actually needs - it + # can't see the car's real SoC, only Predbat can (car_charging_soc/car_charging_limit). + # load_octopus_slots() already caps car_charging_slots[car_n] at the car's real remaining + # requirement (when octopus_intelligent_consider_full is on), zeroing the kwh of any slot + # beyond that - so the 30-min blocks it still lists a positive kwh for are exactly the ones + # the car is still expected to draw on. A future block outside that set is heading for the + # same fate as a rescinded slot: Octopus will reclaim it once it notices the car has stopped + # drawing, so don't commit the house battery to it either. + expected_blocks = set() + if limit_future_slots: + for car_slot in self.car_charging_slots[car_n]: + if car_slot.get("kwh", 0) <= 0: + continue + block_start = (car_slot["start"] // 30) * 30 + block_end = ((car_slot["end"] + 29) // 30) * 30 + expected_blocks.update(range(block_start, block_end, 30)) if octopus_slots: # Add in IO slots @@ -3090,9 +3125,16 @@ 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 future out-of-window slot the car's own real SoC/limit shows it no + # longer needs (#4482) - only applies to slots that haven't started yet, a + # slot already underway or completed is trusted regardless of what + # car_charging_slots now says about future need, and the fixed window is + # never affected since it's guaranteed cheap by the tariff itself. + needed = (not limit_future_slots) or (slot_start <= current_block) or (slot_start in expected_blocks) or self.minute_in_iog_fixed_window(slot_start) + # At the start of each 30-min slot, decide if we can add it if minute % 30 == 0: - if slots_per_day[day_offset] < octopus_slot_max: + if needed 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 @@ -3105,8 +3147,8 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): if minute % 30 == 0 and start_minutes > -24 * 60: self.log( - "Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}".format( - self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate + "Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}, needed {}".format( + self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate, needed ) ) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index caeb54cf6..8cfb0cab6 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -491,6 +491,7 @@ def reset(self): self.octopus_intelligent_charging = False self.octopus_intelligent_ignore_unplugged = False self.octopus_intelligent_consider_full = False + self.octopus_intelligent_limit_future_slots = False self.notify_devices = ["notify"] self.octopus_url_cache = {} self.ge_url_cache = {} diff --git a/apps/predbat/tests/test_fetch_config_options.py b/apps/predbat/tests/test_fetch_config_options.py index a879c4808..efa220df6 100644 --- a/apps/predbat/tests/test_fetch_config_options.py +++ b/apps/predbat/tests/test_fetch_config_options.py @@ -374,6 +374,56 @@ def mock_expose_config(key, value): # Restore num_cars for any tests appended after this one mock_config.config["num_cars"] = 2 + # Test 16: octopus_intelligent_limit_future_slots warns when octopus_intelligent_consider_full + # is off (#4482) - the switch would otherwise have nothing to act on, since + # load_octopus_slots() never zeroes out unneeded future slots without consider_full also on. + print("\n*** Test 16: octopus_intelligent_limit_future_slots warns without consider_full ***") + + original_log = my_predbat.log + log_messages = [] + my_predbat.log = lambda message: log_messages.append(message) + + mock_config.config["octopus_intelligent_limit_future_slots"] = True + mock_config.config["octopus_intelligent_charging"] = True + mock_config.config["octopus_intelligent_consider_full"] = False + + my_predbat.had_errors = False + my_predbat.fetch_config_options() + + detailed_warnings = [msg for msg in log_messages if "has nothing to act on" in msg] + assert len(detailed_warnings) == 1, "Should log the detailed warning exactly once, got {}".format(len(detailed_warnings)) + assert my_predbat.had_errors is True, "Missing consider_full with limit_future_slots on should flag had_errors via record_status" + + # Turning on octopus_intelligent_consider_full should silence the warning + log_messages.clear() + mock_config.config["octopus_intelligent_consider_full"] = True + my_predbat.had_errors = False + + my_predbat.fetch_config_options() + + detailed_warnings = [msg for msg in log_messages if "has nothing to act on" in msg] + assert len(detailed_warnings) == 0, "Should not warn once octopus_intelligent_consider_full is on, got {}".format(detailed_warnings) + assert my_predbat.had_errors is False, "Should not flag had_errors once octopus_intelligent_consider_full is on" + + # Switch off should also silence the warning even without consider_full configured + mock_config.config["octopus_intelligent_consider_full"] = False + mock_config.config["octopus_intelligent_limit_future_slots"] = False + log_messages.clear() + my_predbat.had_errors = False + + my_predbat.fetch_config_options() + + detailed_warnings = [msg for msg in log_messages if "has nothing to act on" in msg] + assert len(detailed_warnings) == 0, "Should not warn when octopus_intelligent_limit_future_slots is off, got {}".format(detailed_warnings) + assert my_predbat.had_errors is False, "Should not flag had_errors when octopus_intelligent_limit_future_slots is off" + + my_predbat.log = original_log + my_predbat.had_errors = False + mock_config.config["octopus_intelligent_limit_future_slots"] = False + mock_config.config["octopus_intelligent_consider_full"] = False + + print("✓ octopus_intelligent_limit_future_slots warning test passed") + # Restore original methods my_predbat.get_arg = original_get_arg my_predbat.manual_times = original_manual_times diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index 484f5ae1e..a75280c4f 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -345,6 +345,95 @@ 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-23 (#4482): octopus_intelligent_limit_future_slots - Octopus often grants more + # daytime dispatch slots than the car actually needs (it can't see the car's real SoC, only + # Predbat can). These check that a future out-of-window slot outside what car_charging_slots + # still lists a positive kwh for doesn't get the low rate, since it's heading for the same + # rescission risk as an unconfirmed slot. + saved_car_charging_slots = my_predbat.car_charging_slots[0] + + # Test 18: EV needs the first 2 of 5 future half-hour dispatches - only those two modify rates. + print("\n**** Test 18: Only future blocks the car still needs get the low rate ****") + slots_18 = [] + for i in range(5): + slot_start = midnight_utc + timedelta(hours=14, minutes=i * 30) + slot_end = slot_start + timedelta(minutes=30) + slots_18.append({"start": slot_start.strftime(TIME_FORMAT), "end": slot_end.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}) + car_slot_start = int(((midnight_utc + timedelta(hours=14)) - midnight_utc).total_seconds() / 60) # 840 + my_predbat.car_charging_slots[0] = [ + {"start": car_slot_start, "end": car_slot_start + 30, "kwh": 2.5, "average": 4, "cost": 10, "soc": 5, "octopus": True}, + {"start": car_slot_start + 30, "end": car_slot_start + 60, "kwh": 2.5, "average": 4, "cost": 10, "soc": 10, "octopus": True}, + {"start": car_slot_start + 60, "end": car_slot_start + 90, "kwh": 0.0, "average": 4, "cost": 0, "soc": 10, "octopus": True}, + {"start": car_slot_start + 90, "end": car_slot_start + 120, "kwh": 0.0, "average": 4, "cost": 0, "soc": 10, "octopus": True}, + {"start": car_slot_start + 120, "end": car_slot_start + 150, "kwh": 0.0, "average": 4, "cost": 0, "soc": 10, "octopus": True}, + ] + expected_rates_18 = {} + for minute in range(car_slot_start, car_slot_start + 60): + expected_rates_18[minute] = 4.0 + for minute in range(car_slot_start + 60, car_slot_start + 150): + expected_rates_18[minute] = 10.0 + my_predbat.octopus_intelligent_limit_future_slots = True + failed |= run_rate_add_io_slots_test("test18_only_needed_future_blocks_low_rate", my_predbat, slots_18, True, 12, expected_rates_18) + + # Test 19: the final required charge ends part-way through a dispatch (14:15 of a 14:00-14:30 + # slot) - the whole touched 30-min settlement period should still be treated as needed, not + # just the covered minutes, matching how the slot rounding elsewhere in this function works. + print("\n**** Test 19: Partially-needed settlement period stays fully low rate ****") + my_predbat.car_charging_slots[0] = [ + {"start": car_slot_start, "end": car_slot_start + 15, "kwh": 1.25, "average": 4, "cost": 5, "soc": 5, "octopus": True}, + {"start": car_slot_start + 15, "end": car_slot_start + 30, "kwh": 0.0, "average": 4, "cost": 0, "soc": 5, "octopus": True}, + {"start": car_slot_start + 30, "end": car_slot_start + 150, "kwh": 0.0, "average": 4, "cost": 0, "soc": 5, "octopus": True}, + ] + expected_rates_19 = {} + for minute in range(car_slot_start, car_slot_start + 30): + expected_rates_19[minute] = 4.0 + for minute in range(car_slot_start + 30, car_slot_start + 150): + expected_rates_19[minute] = 10.0 + failed |= run_rate_add_io_slots_test("test19_partial_settlement_period_stays_low_rate", my_predbat, slots_18, True, 12, expected_rates_19) + + # Test 20: car already at its limit (car_charging_slots has nothing but zero-kwh entries) - no + # dispatch that hasn't started modifies the tariff. + print("\n**** Test 20: Car already full - no future dispatch gets the low rate ****") + my_predbat.car_charging_slots[0] = [{"start": car_slot_start, "end": car_slot_start + 150, "kwh": 0.0, "average": 4, "cost": 0, "soc": 10, "octopus": True}] + expected_rates_20 = {minute: 10.0 for minute in range(car_slot_start, car_slot_start + 150)} + failed |= run_rate_add_io_slots_test("test20_car_already_full_no_low_rate", my_predbat, slots_18, True, 12, expected_rates_20) + + # Test 21: a dispatch already underway (its slot_start is at-or-before minutes_now, 10:00) is + # trusted regardless of what car_charging_slots says about future need - only slots that + # haven't started yet are gated. + print("\n**** Test 21: Current/completed dispatch periods stay low rate regardless ****") + slot_start_21 = midnight_utc + timedelta(hours=9, minutes=30) # 09:30, already underway at 10:00 + 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": "smart-charge", "location": "AT_HOME"}] + my_predbat.car_charging_slots[0] = [] # Car charging plan says nothing is needed at all + expected_rates_21 = {minute: 4.0 for minute in range(570, 600)} # 09:30-10:00 + failed |= run_rate_add_io_slots_test("test21_current_dispatch_stays_low_rate", my_predbat, slots_21, True, 12, expected_rates_21) + + # Test 22: feature disabled - existing (unconditional) behaviour is unchanged even with an + # empty car_charging_slots that would otherwise have excluded every block. + print("\n**** Test 22: Switch off restores unconditional low rate ****") + my_predbat.octopus_intelligent_limit_future_slots = False + my_predbat.car_charging_slots[0] = [] + expected_rates_22 = {} + for minute in range(car_slot_start, car_slot_start + 150): + expected_rates_22[minute] = 4.0 + failed |= run_rate_add_io_slots_test("test22_switch_off_restores_old_behaviour", my_predbat, slots_18, True, 12, expected_rates_22) + + # Test 23: a future out-of-window slot the car doesn't need is excluded, but a slot inside the + # fixed 23:30-05:30 window is never affected regardless, since it's guaranteed cheap by the + # tariff itself, not the dispatch mechanism. + print("\n**** Test 23: Fixed IOG window unaffected regardless of future need ****") + slot_start_23 = midnight_utc + timedelta(hours=2) # 02:00 - well inside 23:30-05:30 + slot_end_23 = slot_start_23 + timedelta(minutes=30) + slots_23 = [{"start": slot_start_23.strftime(TIME_FORMAT), "end": slot_end_23.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}] + my_predbat.octopus_intelligent_limit_future_slots = True + my_predbat.car_charging_slots[0] = [] # Car charging plan says nothing is needed at all + expected_rates_23 = {minute: 4.0 for minute in range(120, 150)} + failed |= run_rate_add_io_slots_test("test23_fixed_window_unaffected", my_predbat, slots_23, True, 12, expected_rates_23) + my_predbat.octopus_intelligent_limit_future_slots = False # Restore default for any subsequent tests + + my_predbat.car_charging_slots[0] = saved_car_charging_slots + # Restore original forecast_minutes my_predbat.forecast_minutes = original_forecast_minutes diff --git a/docs/car-charging.md b/docs/car-charging.md index c810feb47..0ba98fc99 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -461,7 +461,11 @@ Again, if you are using the Octopus Energy direct method for Predbat then these - 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. +By itself, Predbat will still assume all Octopus charging slots are low rates even if some are not used by your car - see **switch.predbat_octopus_intelligent_limit_future_slots** below to also stop the house battery relying on those. + +- The switch **switch.predbat_octopus_intelligent_limit_future_slots** (*expert mode*) (default value is Off, requires **switch.predbat_octopus_intelligent_consider_full** to also be On) protects against Octopus allocating more dispatch slots than your car actually needs. Octopus grants slots based on its own assumption of what the car needs, since it can't see the car's real SoC - only Predbat can, via **car_charging_soc**/**car_charging_limit**. If Octopus has allocated more future slots than the car actually needs, and Predbat has already deferred house battery charging into one of those "surplus" slots, the slot can disappear once Octopus notices the car has stopped drawing power - leaving the battery undercharged with no cheap window left to make it up. +While this switch is On, a *future* out-of-window dispatch slot only counts as low rate for the house battery if it's still within what **car_charging_slots** shows the car genuinely needs (derived from its real remaining SoC requirement) - a slot the car has already fully used or a currently-active/completed dispatch is unaffected regardless, and the fixed 23:30-05:30 window is never affected either, since it's guaranteed cheap by the tariff itself. +It defaults Off, and does nothing at all unless **octopus_intelligent_consider_full** is also On (Predbat logs a warning at startup if you enable this without that) - since it's `consider_full` that actually caps `car_charging_slots` at the car's real remaining need in the first place. - 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 ff62d83ca8351dc655372765318f4554c85500ba Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Thu, 13 Aug 2026 12:31:32 +0100 Subject: [PATCH 2/4] fix(octopus): restore a rejected IOG slot's rate, don't just skip adding a new one Review follow-up on this PR (#4483): for a genuine Octopus Intelligent tariff, fetch_octopus_rates() can receive a dispatch period whose rate is already the Intelligent low rate before rate_add_io_slots() ever runs - rate_replicate() only gap-fills minutes with no real fetched value, so it never touches one that's already there. When rate_add_io_slots() then decides a slot isn't needed (or the daily cap is already reached), `needed=False`/cap-exceeded only prevented adding a *new* low rate; it left whatever was already in rates[] untouched, so an already-discounted fetched rate stayed live even though the slot was just rejected. Confirmed live: a 05:30-06:00 dispatch Predbat correctly determined the car no longer needs, where Octopus's own event data already carried 3.99p (matching the dispatch event's own max_rate) instead of the tariff's ordinary 29.3p out-of-window rate. Now restores rates[minute] to rate_max_base for every minute of a rejected 30-min block (both branches of the existing minute%30==0 split, not just the slot-start minute), and clears self.io_adjusted for the same range - minute_data() (utils.py) sets it for every minute in an adjusted block, not just the first, so a partial clear would leave the plan believing an already-restored minute was still IOG-adjusted. New regression test simulates the exact scenario (a rejected slot with its rates[] pre-seeded as fetch_octopus_rates() would leave it), confirmed to fail without this fix and pass with it. Existing 23 rate_add_io_slots scenarios pass unchanged - their reject-path expectations already happened to equal rate_max_base in that fixture, so this only changes behaviour when rates[] holds something other than the baseline. --- apps/predbat/octopus.py | 19 ++++++++++- apps/predbat/tests/test_rate_add_io_slots.py | 34 ++++++++++++++++++++ 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index f38c81c5d..5c5cb80c0 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3140,10 +3140,27 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): rates[minute] = assumed_price else: assumed_price = self.rate_max_base + # A rejected slot (not needed, or the daily cap already reached) + # must actively restore the ordinary out-of-window rate, not just + # skip adding a new low one. For a genuine Octopus Intelligent + # tariff, fetch_octopus_rates() can already receive the + # dispatch-discounted rate directly (rate_replicate() only + # gap-fills minutes with no real fetched value, so it never + # touches this one) - leaving rates[minute] alone here would keep + # that low rate live even though this slot was just rejected. + rates[minute] = self.rate_max_base + self.io_adjusted.pop(minute, None) else: - # For minutes within a 30-min slot, only apply if the slot was added + # For minutes within a 30-min slot, only apply if the slot was added, + # otherwise restore - matching the slot-start decision above. + # minute_data() (utils.py) sets self.io_adjusted for every minute in + # an adjusted block, not just its first, so the whole block must be + # cleared here too, not just slot_start. if slot_start in slots_added_set: rates[minute] = assumed_price + else: + rates[minute] = self.rate_max_base + self.io_adjusted.pop(minute, None) if minute % 30 == 0 and start_minutes > -24 * 60: self.log( diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index a75280c4f..0f59802e6 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -430,6 +430,40 @@ def run_rate_add_io_slots_tests(my_predbat): my_predbat.car_charging_slots[0] = [] # Car charging plan says nothing is needed at all expected_rates_23 = {minute: 4.0 for minute in range(120, 150)} failed |= run_rate_add_io_slots_test("test23_fixed_window_unaffected", my_predbat, slots_23, True, 12, expected_rates_23) + + # Test 24 (#4483 review follow-up): a rejected future slot must actively restore + # rates[minute] to rate_max_base, not just skip adding a new low rate. For a genuine Octopus + # Intelligent tariff, fetch_octopus_rates() can receive the dispatch-discounted rate directly + # before rate_add_io_slots() ever runs (rate_replicate() only gap-fills minutes with no real + # fetched value, so it never touches this one) - simulate that by pre-seeding the rejected + # slot's minutes with a low rate and self.io_adjusted, then confirm rejection restores both. + print("\n**** Test 24: Rejected slot restores an already-discounted fetched rate ****") + my_predbat.car_charging_slots[0] = [] # Car charging plan says nothing is needed at all + + slot_start_24 = midnight_utc + timedelta(hours=14) # future, out-of-window, car doesn't need it + slot_end_24 = slot_start_24 + timedelta(minutes=30) + slots_24 = [{"start": slot_start_24.strftime(TIME_FORMAT), "end": slot_end_24.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}] + slot_start_minute_24 = int((slot_start_24 - midnight_utc).total_seconds() / 60) + + rates_24 = {} + for minute in range(-96 * 60, max(my_predbat.forecast_minutes, 3 * 24 * 60)): + rates_24[minute] = 10.0 + saved_io_adjusted = dict(my_predbat.io_adjusted) + for minute in range(slot_start_minute_24, slot_start_minute_24 + 30): + rates_24[minute] = 3.99 # already-discounted, as if fetched directly for a real dispatch + my_predbat.io_adjusted[minute] = True # minute_data() marks every minute in the block + + result_rates_24 = my_predbat.rate_add_io_slots(0, rates_24, slots_24) + + for minute in range(slot_start_minute_24, slot_start_minute_24 + 30): + if result_rates_24.get(minute) != my_predbat.rate_max_base: + print("ERROR: Minute {} should be restored to rate_max_base {} but got {}".format(minute, my_predbat.rate_max_base, result_rates_24.get(minute))) + failed = True + if minute in my_predbat.io_adjusted: + print("ERROR: Minute {} should have been cleared from io_adjusted, still present".format(minute)) + failed = True + + my_predbat.io_adjusted = saved_io_adjusted my_predbat.octopus_intelligent_limit_future_slots = False # Restore default for any subsequent tests my_predbat.car_charging_slots[0] = saved_car_charging_slots From 79e1819cf366e0d103579a6045a62edc951efd4d Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Fri, 14 Aug 2026 17:35:54 +0100 Subject: [PATCH 3/4] fix(octopus): scope the destructive rate restore to needed=False, exempt zero-kWh dispatches from the daily cap Two follow-ups from PR review on #4483: 1. The active rate/io_adjusted restore added in the previous commit was reached by *any* rejection in rate_add_io_slots(), including a slot rejected purely because octopus_slot_max was already reached (needed stays True) - a pre-existing, unrelated mechanism that fires even with octopus_intelligent_limit_future_slots Off. That could overwrite a legitimate live rate Predbat was simply choosing not to count against its own daily budget, not one Octopus is known to have rescinded. The restore now only fires when needed is False (Speshman, PR #4483 review). 2. A zero-kWh dispatch entry (e.g. a plug-independent SMART grid-flex event that delivers no energy to the car) is a real tariff discount, but not a car-charging dispatch, so by default it no longer competes for the octopus_slot_max budget or the #4482 "does the car still need this" check - both model car-dispatch behaviour specifically. Gated behind a new switch, octopus_slot_count_zero_kwh (default Off = don't count them), so anyone who wants the old counting behaviour back can restore it. Adds 5 regression tests (25-29) covering both, including the two review specifically asked for (cap-only rejection with the feature Off, and a capped slot inside the fixed 23:30-05:30 window). All fail on the pre-fix code, pass with it. Full suite + pre-commit clean. --- .cspell/custom-dictionary-workspace.txt | 1 + apps/predbat/config.py | 7 + apps/predbat/fetch.py | 1 + apps/predbat/octopus.py | 42 ++++-- apps/predbat/predbat.py | 1 + apps/predbat/tests/test_rate_add_io_slots.py | 131 +++++++++++++++++++ docs/car-charging.md | 4 + 7 files changed, 177 insertions(+), 10 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 19813807e..041140ece 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -541,6 +541,7 @@ solisx sourcery Southwell sparkline +Speshman spki springfall starthour diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 12bb3bbbd..4bbc1e3d9 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -910,6 +910,13 @@ "default": False, "enable": "expert_mode", }, + { + "name": "octopus_slot_count_zero_kwh", + "friendly_name": "Count zero-kWh Intelligent dispatch slots towards the daily octopus_slot_max cap", + "type": "switch", + "default": False, + "enable": "expert_mode", + }, { "name": "car_charging_plan_smart", "friendly_name": "Car Charging Plan Smart", diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index a53ad5e45..ab91f13ac 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -2846,6 +2846,7 @@ def fetch_config_options(self): "Warn: octopus_intelligent_limit_future_slots is On but octopus_intelligent_consider_full is Off - has no effect", had_errors=True, ) + self.octopus_slot_count_zero_kwh = self.get_arg("octopus_slot_count_zero_kwh") 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 5c5cb80c0..9e0d86af5 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3132,24 +3132,46 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # never affected since it's guaranteed cheap by the tariff itself. needed = (not limit_future_slots) or (slot_start <= current_block) or (slot_start in expected_blocks) or self.minute_in_iog_fixed_window(slot_start) + # Whether this dispatch entry actually delivers charge to the car. A + # zero-kWh entry (e.g. a plug-independent SMART grid-flex event - #4483 + # review follow-up) is a real tariff discount Octopus is offering, but it + # isn't a car-charging dispatch: by default it doesn't compete for the + # octopus_slot_max budget (which models Octopus's own ~6-hour + # car-dispatch-per-day limit), and the #4482 "does the car still need + # this" question above doesn't apply either - there's no car draw to need. + # octopus_slot_count_zero_kwh restores the old behaviour of counting every + # dispatch entry, zero-kWh or not, toward the cap like any other. + zero_kwh_exempt = (kwh <= 0) and not self.octopus_slot_count_zero_kwh + # At the start of each 30-min slot, decide if we can add it if minute % 30 == 0: - if needed and slots_per_day[day_offset] < octopus_slot_max: + if zero_kwh_exempt: + slots_added_set.add(slot_start) + rates[minute] = assumed_price + elif needed 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 else: assumed_price = self.rate_max_base - # A rejected slot (not needed, or the daily cap already reached) - # must actively restore the ordinary out-of-window rate, not just - # skip adding a new low one. For a genuine Octopus Intelligent - # tariff, fetch_octopus_rates() can already receive the + # A slot rejected because the car doesn't need it (#4482, + # needed=False) must actively restore the ordinary out-of-window + # rate, not just skip adding a new low one. For a genuine Octopus + # Intelligent tariff, fetch_octopus_rates() can already receive the # dispatch-discounted rate directly (rate_replicate() only # gap-fills minutes with no real fetched value, so it never # touches this one) - leaving rates[minute] alone here would keep # that low rate live even though this slot was just rejected. - rates[minute] = self.rate_max_base - self.io_adjusted.pop(minute, None) + # + # A slot rejected purely because octopus_slot_max was already + # reached (needed is still True here) is left untouched, exactly as + # before this PR (#4483 review follow-up, Speshman): it may still + # be a genuine live dispatch/tariff event Predbat is simply + # choosing not to count against its own budget, not one Octopus is + # known to have rescinded, so overwriting it would be wrong. + if not needed: + rates[minute] = self.rate_max_base + self.io_adjusted.pop(minute, None) else: # For minutes within a 30-min slot, only apply if the slot was added, # otherwise restore - matching the slot-start decision above. @@ -3158,14 +3180,14 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # cleared here too, not just slot_start. if slot_start in slots_added_set: rates[minute] = assumed_price - else: + elif not needed: rates[minute] = self.rate_max_base self.io_adjusted.pop(minute, None) 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 {}, needed {}".format( - self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate, needed + "Octopus: Intelligent slot at {}-{}, assumed price {}, amount {}, kWh location {}, source {}, octopus_slot_low_rate {}, needed {}, zero_kwh_exempt {}".format( + self.time_abs_str(start_minutes), self.time_abs_str(end_minutes), dp2(assumed_price), dp2(kwh), location, source, octopus_slot_low_rate, needed, zero_kwh_exempt ) ) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 8cfb0cab6..a8b67a85a 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -492,6 +492,7 @@ def reset(self): self.octopus_intelligent_ignore_unplugged = False self.octopus_intelligent_consider_full = False self.octopus_intelligent_limit_future_slots = False + self.octopus_slot_count_zero_kwh = False self.notify_devices = ["notify"] self.octopus_url_cache = {} self.ge_url_cache = {} diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index 0f59802e6..940bc428c 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -468,6 +468,137 @@ def run_rate_add_io_slots_tests(my_predbat): my_predbat.car_charging_slots[0] = saved_car_charging_slots + # Test 25 (#4483 review follow-up, Speshman): a slot rejected purely because octopus_slot_max + # was already reached (needed stays True throughout - this happens even with + # octopus_intelligent_limit_future_slots Off, since the daily cap is a pre-existing, unrelated + # mechanism) must NOT destructively restore rates[minute] - that active restore is reserved + # for the needed=False case (#4482) above, where Predbat has positive reason to believe + # Octopus has rescinded the slot. A cap-rejected slot may still be a genuine live + # dispatch/tariff event; Predbat is only choosing not to count it against its own budget. + print("\n**** Test 25: Cap-only rejection leaves an already-discounted fetched rate alone ****") + my_predbat.octopus_intelligent_limit_future_slots = False # feature off - needed is always True regardless + + slot_start_25a = midnight_utc + timedelta(hours=1) # 01:00-01:30, consumes the only cap slot for the day + slot_end_25a = slot_start_25a + timedelta(minutes=30) + slot_start_25b = slot_end_25a # 01:30-02:00, rejected purely because the cap is already spent + slot_end_25b = slot_start_25b + timedelta(minutes=30) + slots_25 = [ + {"start": slot_start_25a.strftime(TIME_FORMAT), "end": slot_end_25a.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}, + {"start": slot_start_25b.strftime(TIME_FORMAT), "end": slot_end_25b.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}, + ] + slot_start_minute_25a = int((slot_start_25a - midnight_utc).total_seconds() / 60) + slot_start_minute_25b = int((slot_start_25b - midnight_utc).total_seconds() / 60) + + my_predbat.args["octopus_slot_low_rate"] = True + my_predbat.args["octopus_slot_max"] = 1 + rates_25 = {} + for minute in range(-96 * 60, max(my_predbat.forecast_minutes, 3 * 24 * 60)): + rates_25[minute] = 10.0 + saved_io_adjusted_25 = dict(my_predbat.io_adjusted) + for minute in range(slot_start_minute_25b, slot_start_minute_25b + 30): + rates_25[minute] = 3.99 # already-discounted, as if fetched directly for a real dispatch + my_predbat.io_adjusted[minute] = True # minute_data() marks every minute in the block + + result_rates_25 = my_predbat.rate_add_io_slots(0, rates_25, slots_25) + + for minute in range(slot_start_minute_25a, slot_start_minute_25a + 30): + if result_rates_25.get(minute) != my_predbat.rate_min_base: + print("ERROR: Minute {} (first, within cap) should be the low rate {} but got {}".format(minute, my_predbat.rate_min_base, result_rates_25.get(minute))) + failed = True + for minute in range(slot_start_minute_25b, slot_start_minute_25b + 30): + if result_rates_25.get(minute) != 3.99: + print("ERROR: Minute {} (second, cap-only rejection) should be left at the already-discounted 3.99, got {}".format(minute, result_rates_25.get(minute))) + failed = True + if minute not in my_predbat.io_adjusted: + print("ERROR: Minute {} should still be marked io_adjusted (cap-only rejection), was cleared".format(minute)) + failed = True + + my_predbat.io_adjusted = saved_io_adjusted_25 + + # Test 26 (#4483 review follow-up, Speshman): the same cap-only-rejection preservation + # applies to a slot inside the guaranteed 23:30-05:30 fixed window too - it's still just a + # cap-driven rejection (needed stays True via the fixed-window clause), not a needed=False + # rescission, so the destructive restore must not fire there either. + print("\n**** Test 26: Cap-only rejection inside the fixed window also leaves the rate alone ****") + my_predbat.octopus_intelligent_limit_future_slots = True # needed forced True here via the fixed window, not the "off" shortcut + my_predbat.car_charging_slots[0] = [] # car charging plan says nothing is needed - irrelevant, fixed window forces needed=True anyway + + slot_start_26a = midnight_utc + timedelta(hours=1) # 01:00-01:30, inside 23:30-05:30, consumes the only cap slot + slot_end_26a = slot_start_26a + timedelta(minutes=30) + slot_start_26b = slot_end_26a # 01:30-02:00, also inside the fixed window, rejected purely by the cap + slot_end_26b = slot_start_26b + timedelta(minutes=30) + slots_26 = [ + {"start": slot_start_26a.strftime(TIME_FORMAT), "end": slot_end_26a.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}, + {"start": slot_start_26b.strftime(TIME_FORMAT), "end": slot_end_26b.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}, + ] + slot_start_minute_26b = int((slot_start_26b - midnight_utc).total_seconds() / 60) + + rates_26 = {} + for minute in range(-96 * 60, max(my_predbat.forecast_minutes, 3 * 24 * 60)): + rates_26[minute] = 10.0 + saved_io_adjusted_26 = dict(my_predbat.io_adjusted) + for minute in range(slot_start_minute_26b, slot_start_minute_26b + 30): + rates_26[minute] = 3.99 + my_predbat.io_adjusted[minute] = True + + result_rates_26 = my_predbat.rate_add_io_slots(0, rates_26, slots_26) + + for minute in range(slot_start_minute_26b, slot_start_minute_26b + 30): + if result_rates_26.get(minute) != 3.99: + print("ERROR: Minute {} (fixed-window, cap-only rejection) should be left at 3.99, got {}".format(minute, result_rates_26.get(minute))) + failed = True + if minute not in my_predbat.io_adjusted: + print("ERROR: Minute {} (fixed-window) should still be marked io_adjusted, was cleared".format(minute)) + failed = True + + my_predbat.io_adjusted = saved_io_adjusted_26 + my_predbat.octopus_intelligent_limit_future_slots = False + my_predbat.car_charging_slots[0] = saved_car_charging_slots + + # Tests 27-29 (#4483 review follow-up): octopus_slot_count_zero_kwh - a zero-kWh dispatch + # entry (e.g. a plug-independent SMART grid-flex event that delivers no energy to the car) + # is a real tariff discount, but not a car-charging dispatch. By default it's exempt from + # both the #4482 "does the car still need this" check and the octopus_slot_max cap. + + print("\n**** Test 27: Zero-kWh slot gets the low rate even when the car doesn't need it ****") + my_predbat.octopus_intelligent_limit_future_slots = True + my_predbat.car_charging_slots[0] = [] # car doesn't need anything - would normally reject a future out-of-window slot + slot_start_27 = midnight_utc + timedelta(hours=14) # future, out-of-window + slot_end_27 = slot_start_27 + timedelta(minutes=30) + slots_27 = [{"start": slot_start_27.strftime(TIME_FORMAT), "end": slot_end_27.strftime(TIME_FORMAT), "charge_in_kwh": 0.0, "source": "SMART", "location": ""}] + slot_start_minute_27 = int((slot_start_27 - midnight_utc).total_seconds() / 60) + expected_rates_27 = {minute: 4.0 for minute in range(slot_start_minute_27, slot_start_minute_27 + 30)} + failed |= run_rate_add_io_slots_test("test27_zero_kwh_exempt_by_default", my_predbat, slots_27, True, 12, expected_rates_27) + + print("\n**** Test 28: Zero-kWh slot does not consume octopus_slot_max budget ****") + my_predbat.octopus_intelligent_limit_future_slots = False + slot_start_28a = midnight_utc + timedelta(hours=1) # zero-kWh, first + slot_end_28a = slot_start_28a + timedelta(minutes=30) + slot_start_28b = slot_end_28a # genuine, kwh>0, second - must still fit under cap=1 + slot_end_28b = slot_start_28b + timedelta(minutes=30) + slots_28 = [ + {"start": slot_start_28a.strftime(TIME_FORMAT), "end": slot_end_28a.strftime(TIME_FORMAT), "charge_in_kwh": 0.0, "source": "SMART", "location": ""}, + {"start": slot_start_28b.strftime(TIME_FORMAT), "end": slot_end_28b.strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "smart-charge", "location": "AT_HOME"}, + ] + slot_start_minute_28a = int((slot_start_28a - midnight_utc).total_seconds() / 60) + expected_rates_28 = {minute: 4.0 for minute in range(slot_start_minute_28a, slot_start_minute_28a + 60)} # both slots cheap + failed |= run_rate_add_io_slots_test("test28_zero_kwh_does_not_spend_cap", my_predbat, slots_28, True, 1, expected_rates_28) + + print("\n**** Test 29: Switch on makes zero-kWh slots count like any other ****") + my_predbat.octopus_slot_count_zero_kwh = True + my_predbat.octopus_intelligent_limit_future_slots = True + my_predbat.car_charging_slots[0] = [] # car doesn't need anything + slot_start_29 = midnight_utc + timedelta(hours=14) # future, out-of-window, zero-kWh + slot_end_29 = slot_start_29 + timedelta(minutes=30) + slots_29 = [{"start": slot_start_29.strftime(TIME_FORMAT), "end": slot_end_29.strftime(TIME_FORMAT), "charge_in_kwh": 0.0, "source": "SMART", "location": ""}] + slot_start_minute_29 = int((slot_start_29 - midnight_utc).total_seconds() / 60) + expected_rates_29 = {minute: 10.0 for minute in range(slot_start_minute_29, slot_start_minute_29 + 30)} # rejected, not needed, and now counted + failed |= run_rate_add_io_slots_test("test29_switch_on_zero_kwh_subject_to_needed_gate", my_predbat, slots_29, True, 12, expected_rates_29) + my_predbat.octopus_slot_count_zero_kwh = False # restore default + + my_predbat.octopus_intelligent_limit_future_slots = False # Restore default for any subsequent tests + my_predbat.car_charging_slots[0] = saved_car_charging_slots + # Restore original forecast_minutes my_predbat.forecast_minutes = original_forecast_minutes diff --git a/docs/car-charging.md b/docs/car-charging.md index 0ba98fc99..20e3ebdec 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -467,6 +467,10 @@ By itself, Predbat will still assume all Octopus charging slots are low rates ev While this switch is On, a *future* out-of-window dispatch slot only counts as low rate for the house battery if it's still within what **car_charging_slots** shows the car genuinely needs (derived from its real remaining SoC requirement) - a slot the car has already fully used or a currently-active/completed dispatch is unaffected regardless, and the fixed 23:30-05:30 window is never affected either, since it's guaranteed cheap by the tariff itself. It defaults Off, and does nothing at all unless **octopus_intelligent_consider_full** is also On (Predbat logs a warning at startup if you enable this without that) - since it's `consider_full` that actually caps `car_charging_slots` at the car's real remaining need in the first place. +- The switch **switch.predbat_octopus_slot_count_zero_kwh** (*expert mode*) (default value is Off) controls whether zero-kWh Intelligent dispatch entries (for example a plug-independent SMART grid-flex event that Octopus schedules but that delivers no energy to your car) count towards the **octopus_slot_max** daily cap. +By default (Off) they don't: a zero-kWh entry is still treated as a genuine low rate, but since no car charging actually happens in it, it neither spends nor is blocked by the cap that's meant to model Octopus's own limit on car-dispatch slots per day. +Turn this On to restore the previous behaviour, where every Intelligent dispatch entry counts towards the cap regardless of whether your car drew any energy from it. + - 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 d0100fb68481f0c46fe5d3e28cf1e968d0a56238 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Fri, 28 Aug 2026 11:08:10 +0100 Subject: [PATCH 4/4] fix(octopus): scope the zero-kWh exemption to source == "SMART" decode_octopus_slot() silently coerces malformed/unparseable charge_in_kwh input to 0.0 - indistinguishable by value alone from a genuine zero-kWh SMART grid-flex event. Without this, a garbage entry with any source could bypass both the octopus_slot_max cap and the #4482 need-check. #4483 review follow-up, Speshman. --- apps/predbat/octopus.py | 11 ++++++++++- apps/predbat/tests/test_rate_add_io_slots.py | 15 +++++++++++++++ 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 9e0d86af5..360cd8ec8 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -3141,7 +3141,16 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots): # this" question above doesn't apply either - there's no car draw to need. # octopus_slot_count_zero_kwh restores the old behaviour of counting every # dispatch entry, zero-kWh or not, toward the cap like any other. - zero_kwh_exempt = (kwh <= 0) and not self.octopus_slot_count_zero_kwh + # + # Scoped to source == "SMART" (#4483 review follow-up, Speshman): kwh only + # ever reaches 0 either from a genuine zero-kWh entry or from + # decode_octopus_slot() silently coercing malformed/unparseable input to + # 0.0 - the two are indistinguishable by value alone. Requiring the source + # this feature was actually built for narrows a parse failure exploiting the + # exemption to the coincidence of also carrying source=="SMART", rather than + # any garbage entry with any source bypassing both the cap and the + # #4482 need-check. + zero_kwh_exempt = (kwh <= 0) and (source == "SMART") and not self.octopus_slot_count_zero_kwh # At the start of each 30-min slot, decide if we can add it if minute % 30 == 0: diff --git a/apps/predbat/tests/test_rate_add_io_slots.py b/apps/predbat/tests/test_rate_add_io_slots.py index 940bc428c..2b4615540 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -596,6 +596,21 @@ def run_rate_add_io_slots_tests(my_predbat): failed |= run_rate_add_io_slots_test("test29_switch_on_zero_kwh_subject_to_needed_gate", my_predbat, slots_29, True, 12, expected_rates_29) my_predbat.octopus_slot_count_zero_kwh = False # restore default + # Test 30 (#4483 review follow-up, Speshman): the zero-kWh exemption is scoped to + # source == "SMART" - decode_octopus_slot() silently coerces malformed/unparseable + # charge_in_kwh input to 0.0, indistinguishable by value alone from a genuine zero-kWh + # SMART event. A zero-kWh entry with any other source must not get the exemption - it goes + # through the same needed/cap gate as any other slot, and here the car doesn't need it. + print("\n**** Test 30: Zero-kWh slot with a non-SMART source is not exempt ****") + my_predbat.octopus_intelligent_limit_future_slots = True + my_predbat.car_charging_slots[0] = [] # car doesn't need anything + slot_start_30 = midnight_utc + timedelta(hours=14) # future, out-of-window, zero-kWh + slot_end_30 = slot_start_30 + timedelta(minutes=30) + slots_30 = [{"start": slot_start_30.strftime(TIME_FORMAT), "end": slot_end_30.strftime(TIME_FORMAT), "charge_in_kwh": 0.0, "source": "smart-charge", "location": "AT_HOME"}] + slot_start_minute_30 = int((slot_start_30 - midnight_utc).total_seconds() / 60) + expected_rates_30 = {minute: 10.0 for minute in range(slot_start_minute_30, slot_start_minute_30 + 30)} # rejected, not needed, not exempt + failed |= run_rate_add_io_slots_test("test30_zero_kwh_non_smart_source_not_exempt", my_predbat, slots_30, True, 12, expected_rates_30) + my_predbat.octopus_intelligent_limit_future_slots = False # Restore default for any subsequent tests my_predbat.car_charging_slots[0] = saved_car_charging_slots