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 6028b85a9..4bbc1e3d9 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -903,6 +903,20 @@ "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": "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 4c80a452d..ab91f13ac 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -2834,6 +2834,19 @@ 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.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 c640b740c..360cd8ec8 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,23 +3125,78 @@ 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) + + # 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. + # + # 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: - if 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 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. + # + # 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 + # 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 + 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 {}".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 {}, 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 caeb54cf6..a8b67a85a 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -491,6 +491,8 @@ 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.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_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..2b4615540 100644 --- a/apps/predbat/tests/test_rate_add_io_slots.py +++ b/apps/predbat/tests/test_rate_add_io_slots.py @@ -345,6 +345,275 @@ 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) + + # 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 + + # 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 + + # 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 + # 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..20e3ebdec 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -461,7 +461,15 @@ 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_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