diff --git a/apps/predbat/octopus.py b/apps/predbat/octopus.py index 1956d2e84..20a4c8fb1 100644 --- a/apps/predbat/octopus.py +++ b/apps/predbat/octopus.py @@ -2642,70 +2642,92 @@ def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_ # Add in the current charging slot for slot in slots_sorted: start_minutes, end_minutes, kwh, source, location = slot - kwh_original = kwh - end_minutes_original = end_minutes - # Determine rate for this slot, applying the midday-to-midday cap - slot_average = self.rate_import.get(start_minutes, self.rate_min_base) + # Determine rate for this slot, applying the midday-to-midday cap. A slot that only + # partly fits the remaining daily budget is split at the point the budget runs out - + # e.g. a single 8-hour overnight IOG dispatch with only 4 of its 12 daily blocks left + # gets 4 blocks at the low rate and the other 12 at the max rate, not the whole 16 + # blocks flipped to max rate the way an all-or-nothing check would (batpred#4624). + # kWh is apportioned to each chunk by its share of the slot's duration - an + # approximation (real draw isn't perfectly uniform across the slot) but matches how + # rate_add_io_slots() below treats rate as uniform per 30-min block too. + chunks = [(start_minutes, end_minutes, kwh, self.rate_import.get(start_minutes, self.rate_min_base))] if octopus_slot_low_rate and source != "bump-charge" and source != "BOOST" and (not location or location == "AT_HOME"): - # Count 30-min blocks for this slot against the midday-to-midday cap slot_block_start = (start_minutes // 30) * 30 num_blocks = max(1, (end_minutes - slot_block_start + 29) // 30) day_offset = (start_minutes - 720) // (24 * 60) if day_offset not in slots_per_day: slots_per_day[day_offset] = 0 - if slots_per_day[day_offset] + num_blocks <= octopus_slot_max: - slots_per_day[day_offset] += num_blocks - slot_average = self.rate_min_base + available_blocks = max(0, octopus_slot_max - slots_per_day[day_offset]) + slots_per_day[day_offset] += min(num_blocks, available_blocks) + + if available_blocks >= num_blocks: + chunks = [(start_minutes, end_minutes, kwh, self.rate_min_base)] + elif available_blocks <= 0: + chunks = [(start_minutes, end_minutes, kwh, self.rate_max_base)] else: - slot_average = self.rate_max_base - - if (end_minutes > start_minutes) and (end_minutes > self.minutes_now) and (not location or location == "AT_HOME"): - kwh_expected = kwh * self.car_charging_loss - if octopus_intelligent_consider_full: - kwh_expected = max(min(kwh_expected, limit - car_soc), 0) - kwh = dp2(kwh_expected / self.car_charging_loss) - - # Remove the remaining unused time - if octopus_intelligent_consider_full and kwh > 0 and (min(car_soc + kwh_expected, limit) >= limit): - required_extra_soc = max(limit - car_soc, 0) - required_minutes = int(required_extra_soc / (kwh_original * self.car_charging_loss) * (end_minutes - start_minutes) + 0.5) - required_minutes = min(required_minutes, end_minutes - start_minutes) - end_minutes = start_minutes + required_minutes - - car_soc = min(car_soc + kwh_expected, limit) - new_slot = {} - new_slot["start"] = start_minutes - new_slot["end"] = end_minutes - new_slot["kwh"] = kwh - new_slot["average"] = slot_average - new_slot["cost"] = dp2(new_slot["average"] * kwh) - new_slot["soc"] = dp2(car_soc) - new_slot["octopus"] = True - new_slots.append(new_slot) - - if end_minutes_original > end_minutes: + # Full precision here - rounding to dp2() on both sides can make low_kwh + high_kwh + # drift from the original kwh, and this function's non-split chunks already carry + # kwh at full precision too (dp2() is only applied downstream, to cost/soc). + split_minute = min(slot_block_start + available_blocks * 30, end_minutes) + span = end_minutes - start_minutes + low_kwh = kwh * (split_minute - start_minutes) / span if span > 0 else 0.0 + chunks = [ + (start_minutes, split_minute, low_kwh, self.rate_min_base), + (split_minute, end_minutes, kwh - low_kwh, self.rate_max_base), + ] + + for chunk_start, chunk_end, chunk_kwh, slot_average in chunks: + kwh_original = chunk_kwh + end_minutes_original = chunk_end + start_minutes, end_minutes, kwh = chunk_start, chunk_end, chunk_kwh + + if (end_minutes > start_minutes) and (end_minutes > self.minutes_now) and (not location or location == "AT_HOME"): + kwh_expected = kwh * self.car_charging_loss + if octopus_intelligent_consider_full: + kwh_expected = max(min(kwh_expected, limit - car_soc), 0) + kwh = dp2(kwh_expected / self.car_charging_loss) + + # Remove the remaining unused time + if octopus_intelligent_consider_full and kwh > 0 and (min(car_soc + kwh_expected, limit) >= limit): + required_extra_soc = max(limit - car_soc, 0) + required_minutes = int(required_extra_soc / (kwh_original * self.car_charging_loss) * (end_minutes - start_minutes) + 0.5) if kwh_original > 0 else 0 + required_minutes = min(required_minutes, end_minutes - start_minutes) + end_minutes = start_minutes + required_minutes + + car_soc = min(car_soc + kwh_expected, limit) new_slot = {} - new_slot["start"] = end_minutes - new_slot["end"] = end_minutes_original - new_slot["kwh"] = 0.0 + new_slot["start"] = start_minutes + new_slot["end"] = end_minutes + new_slot["kwh"] = kwh new_slot["average"] = slot_average - new_slot["cost"] = 0.0 + new_slot["cost"] = dp2(new_slot["average"] * kwh) new_slot["soc"] = dp2(car_soc) new_slot["octopus"] = True new_slots.append(new_slot) - else: - car_soc = min(car_soc + kwh_expected, limit) - new_slot = {} - new_slot["start"] = start_minutes - new_slot["end"] = end_minutes - new_slot["kwh"] = kwh - new_slot["average"] = slot_average - new_slot["cost"] = dp2(new_slot["average"] * kwh) - new_slot["soc"] = dp2(car_soc) - new_slot["octopus"] = True - new_slots.append(new_slot) + if end_minutes_original > end_minutes: + new_slot = {} + new_slot["start"] = end_minutes + new_slot["end"] = end_minutes_original + new_slot["kwh"] = 0.0 + new_slot["average"] = slot_average + new_slot["cost"] = 0.0 + new_slot["soc"] = dp2(car_soc) + new_slot["octopus"] = True + new_slots.append(new_slot) + + else: + car_soc = min(car_soc + kwh_expected, limit) + new_slot = {} + new_slot["start"] = start_minutes + new_slot["end"] = end_minutes + new_slot["kwh"] = kwh + new_slot["average"] = slot_average + new_slot["cost"] = dp2(new_slot["average"] * kwh) + new_slot["soc"] = dp2(car_soc) + new_slot["octopus"] = True + new_slots.append(new_slot) return new_slots def rate_add_io_slots(self, car_n, rates, octopus_slots): diff --git a/apps/predbat/tests/test_octopus_slots.py b/apps/predbat/tests/test_octopus_slots.py index 145e89a01..2863b82c7 100644 --- a/apps/predbat/tests/test_octopus_slots.py +++ b/apps/predbat/tests/test_octopus_slots.py @@ -114,7 +114,19 @@ def run_load_octopus_slots_tests(my_predbat): expected_slots4.append({"start": minutes_start, "end": minutes_end, "kwh": 5.0 if soc <= 20.0 else 0.0, "average": slot7_rate, "cost": (5.0 if soc <= 20.0 else 0.0) * slot7_rate, "soc": min(soc2, 10.0), "octopus": True}) # Slots 4-8 (i >= 3) exceed the 12-block cap for slots6 (slot 0 is 90-min = 4 blocks, slots 1-2 = 3 blocks each = 10 total, slot 3 would be 13) slot5_rate = 4 if i < 3 else 10 - if i >= 1: + if i == 3: + # This slot straddles the cap exactly (10 blocks already used of 12, this slot needs 3 + # more) - split at the point the daily budget runs out rather than the whole slot + # flipping to the max rate (batpred#4624). + slot_block_start = (minutes_start // 30) * 30 + split_minute = slot_block_start + 2 * 30 # 2 blocks (12 - 10) remain in the daily budget + # Full precision to match production - only cost is rounded (batpred#4644 review). + low_kwh = 5.0 * (split_minute - minutes_start) / 60 + high_kwh = 5.0 - low_kwh + for target in (expected_slots5, expected_slots8): + target.append({"start": minutes_start, "end": split_minute, "kwh": low_kwh, "average": 4, "cost": dp2(4 * low_kwh), "soc": 10, "octopus": True}) + target.append({"start": split_minute, "end": minutes_end, "kwh": high_kwh, "average": 10, "cost": dp2(10 * high_kwh), "soc": 10, "octopus": True}) + elif i >= 1: expected_slots5.append({"start": minutes_start, "end": minutes_end, "kwh": 5.0, "average": slot5_rate, "cost": slot5_rate * 5.0, "soc": 10, "octopus": True}) expected_slots8.append({"start": minutes_start, "end": minutes_end, "kwh": 5.0, "average": slot5_rate, "cost": slot5_rate * 5.0, "soc": 10, "octopus": True}) else: @@ -202,6 +214,12 @@ def run_load_octopus_slots_tests(my_predbat): # it around a fully-contained earlier slot. print("**** Checking containment overlap (completed dispatch inside planned dispatch) ****") saved_minutes_now = my_predbat.minutes_now + # This test is about overlap/containment handling specifically, not the daily low-rate block + # cap (batpred#4624's split logic) - the 540-960 remainder is 14 blocks, which would otherwise + # get split again by the still-active octopus_slot_max=12 from earlier in this test, coupling + # two independent behaviours together. Lift the cap for just this check. + saved_octopus_slot_max = my_predbat.args.get("octopus_slot_max") + my_predbat.args["octopus_slot_max"] = 999 containment_now = midnight_utc + timedelta(hours=10, minutes=37) my_predbat.minutes_now = int((containment_now - midnight_utc).total_seconds() / 60) @@ -238,6 +256,10 @@ def run_load_octopus_slots_tests(my_predbat): failed = True my_predbat.minutes_now = saved_minutes_now + if saved_octopus_slot_max is None: + my_predbat.args.pop("octopus_slot_max", None) + else: + my_predbat.args["octopus_slot_max"] = saved_octopus_slot_max if failed: return failed