Skip to content

Commit 8d3cec1

Browse files
Merge pull request #4644 from springfall2008/fix/octopus-iog-cap-split
fix(octopus): split IOG slots at the daily low-rate cap boundary
2 parents a4b71d9 + 2e9f191 commit 8d3cec1

2 files changed

Lines changed: 95 additions & 51 deletions

File tree

apps/predbat/octopus.py

Lines changed: 72 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -2670,70 +2670,92 @@ def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_
26702670
# Add in the current charging slot
26712671
for slot in slots_sorted:
26722672
start_minutes, end_minutes, kwh, source, location = slot
2673-
kwh_original = kwh
2674-
end_minutes_original = end_minutes
26752673

2676-
# Determine rate for this slot, applying the midday-to-midday cap
2677-
slot_average = self.rate_import.get(start_minutes, self.rate_min_base)
2674+
# Determine rate for this slot, applying the midday-to-midday cap. A slot that only
2675+
# partly fits the remaining daily budget is split at the point the budget runs out -
2676+
# e.g. a single 8-hour overnight IOG dispatch with only 4 of its 12 daily blocks left
2677+
# gets 4 blocks at the low rate and the other 12 at the max rate, not the whole 16
2678+
# blocks flipped to max rate the way an all-or-nothing check would (batpred#4624).
2679+
# kWh is apportioned to each chunk by its share of the slot's duration - an
2680+
# approximation (real draw isn't perfectly uniform across the slot) but matches how
2681+
# rate_add_io_slots() below treats rate as uniform per 30-min block too.
2682+
chunks = [(start_minutes, end_minutes, kwh, self.rate_import.get(start_minutes, self.rate_min_base))]
26782683
if octopus_slot_low_rate and source != "bump-charge" and source != "BOOST" and (not location or location == "AT_HOME"):
2679-
# Count 30-min blocks for this slot against the midday-to-midday cap
26802684
slot_block_start = (start_minutes // 30) * 30
26812685
num_blocks = max(1, (end_minutes - slot_block_start + 29) // 30)
26822686
day_offset = (start_minutes - 720) // (24 * 60)
26832687
if day_offset not in slots_per_day:
26842688
slots_per_day[day_offset] = 0
2685-
if slots_per_day[day_offset] + num_blocks <= octopus_slot_max:
2686-
slots_per_day[day_offset] += num_blocks
2687-
slot_average = self.rate_min_base
2689+
available_blocks = max(0, octopus_slot_max - slots_per_day[day_offset])
2690+
slots_per_day[day_offset] += min(num_blocks, available_blocks)
2691+
2692+
if available_blocks >= num_blocks:
2693+
chunks = [(start_minutes, end_minutes, kwh, self.rate_min_base)]
2694+
elif available_blocks <= 0:
2695+
chunks = [(start_minutes, end_minutes, kwh, self.rate_max_base)]
26882696
else:
2689-
slot_average = self.rate_max_base
2690-
2691-
if (end_minutes > start_minutes) and (end_minutes > self.minutes_now) and (not location or location == "AT_HOME"):
2692-
kwh_expected = kwh * self.car_charging_loss
2693-
if octopus_intelligent_consider_full:
2694-
kwh_expected = max(min(kwh_expected, limit - car_soc), 0)
2695-
kwh = dp2(kwh_expected / self.car_charging_loss)
2696-
2697-
# Remove the remaining unused time
2698-
if octopus_intelligent_consider_full and kwh > 0 and (min(car_soc + kwh_expected, limit) >= limit):
2699-
required_extra_soc = max(limit - car_soc, 0)
2700-
required_minutes = int(required_extra_soc / (kwh_original * self.car_charging_loss) * (end_minutes - start_minutes) + 0.5)
2701-
required_minutes = min(required_minutes, end_minutes - start_minutes)
2702-
end_minutes = start_minutes + required_minutes
2703-
2704-
car_soc = min(car_soc + kwh_expected, limit)
2705-
new_slot = {}
2706-
new_slot["start"] = start_minutes
2707-
new_slot["end"] = end_minutes
2708-
new_slot["kwh"] = kwh
2709-
new_slot["average"] = slot_average
2710-
new_slot["cost"] = dp2(new_slot["average"] * kwh)
2711-
new_slot["soc"] = dp2(car_soc)
2712-
new_slot["octopus"] = True
2713-
new_slots.append(new_slot)
2714-
2715-
if end_minutes_original > end_minutes:
2697+
# Full precision here - rounding to dp2() on both sides can make low_kwh + high_kwh
2698+
# drift from the original kwh, and this function's non-split chunks already carry
2699+
# kwh at full precision too (dp2() is only applied downstream, to cost/soc).
2700+
split_minute = min(slot_block_start + available_blocks * 30, end_minutes)
2701+
span = end_minutes - start_minutes
2702+
low_kwh = kwh * (split_minute - start_minutes) / span if span > 0 else 0.0
2703+
chunks = [
2704+
(start_minutes, split_minute, low_kwh, self.rate_min_base),
2705+
(split_minute, end_minutes, kwh - low_kwh, self.rate_max_base),
2706+
]
2707+
2708+
for chunk_start, chunk_end, chunk_kwh, slot_average in chunks:
2709+
kwh_original = chunk_kwh
2710+
end_minutes_original = chunk_end
2711+
start_minutes, end_minutes, kwh = chunk_start, chunk_end, chunk_kwh
2712+
2713+
if (end_minutes > start_minutes) and (end_minutes > self.minutes_now) and (not location or location == "AT_HOME"):
2714+
kwh_expected = kwh * self.car_charging_loss
2715+
if octopus_intelligent_consider_full:
2716+
kwh_expected = max(min(kwh_expected, limit - car_soc), 0)
2717+
kwh = dp2(kwh_expected / self.car_charging_loss)
2718+
2719+
# Remove the remaining unused time
2720+
if octopus_intelligent_consider_full and kwh > 0 and (min(car_soc + kwh_expected, limit) >= limit):
2721+
required_extra_soc = max(limit - car_soc, 0)
2722+
required_minutes = int(required_extra_soc / (kwh_original * self.car_charging_loss) * (end_minutes - start_minutes) + 0.5) if kwh_original > 0 else 0
2723+
required_minutes = min(required_minutes, end_minutes - start_minutes)
2724+
end_minutes = start_minutes + required_minutes
2725+
2726+
car_soc = min(car_soc + kwh_expected, limit)
27162727
new_slot = {}
2717-
new_slot["start"] = end_minutes
2718-
new_slot["end"] = end_minutes_original
2719-
new_slot["kwh"] = 0.0
2728+
new_slot["start"] = start_minutes
2729+
new_slot["end"] = end_minutes
2730+
new_slot["kwh"] = kwh
27202731
new_slot["average"] = slot_average
2721-
new_slot["cost"] = 0.0
2732+
new_slot["cost"] = dp2(new_slot["average"] * kwh)
27222733
new_slot["soc"] = dp2(car_soc)
27232734
new_slot["octopus"] = True
27242735
new_slots.append(new_slot)
27252736

2726-
else:
2727-
car_soc = min(car_soc + kwh_expected, limit)
2728-
new_slot = {}
2729-
new_slot["start"] = start_minutes
2730-
new_slot["end"] = end_minutes
2731-
new_slot["kwh"] = kwh
2732-
new_slot["average"] = slot_average
2733-
new_slot["cost"] = dp2(new_slot["average"] * kwh)
2734-
new_slot["soc"] = dp2(car_soc)
2735-
new_slot["octopus"] = True
2736-
new_slots.append(new_slot)
2737+
if end_minutes_original > end_minutes:
2738+
new_slot = {}
2739+
new_slot["start"] = end_minutes
2740+
new_slot["end"] = end_minutes_original
2741+
new_slot["kwh"] = 0.0
2742+
new_slot["average"] = slot_average
2743+
new_slot["cost"] = 0.0
2744+
new_slot["soc"] = dp2(car_soc)
2745+
new_slot["octopus"] = True
2746+
new_slots.append(new_slot)
2747+
2748+
else:
2749+
car_soc = min(car_soc + kwh_expected, limit)
2750+
new_slot = {}
2751+
new_slot["start"] = start_minutes
2752+
new_slot["end"] = end_minutes
2753+
new_slot["kwh"] = kwh
2754+
new_slot["average"] = slot_average
2755+
new_slot["cost"] = dp2(new_slot["average"] * kwh)
2756+
new_slot["soc"] = dp2(car_soc)
2757+
new_slot["octopus"] = True
2758+
new_slots.append(new_slot)
27372759
return new_slots
27382760

27392761
def rate_add_io_slots(self, car_n, rates, octopus_slots):

apps/predbat/tests/test_octopus_slots.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -118,7 +118,19 @@ def run_load_octopus_slots_tests(my_predbat):
118118
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})
119119
# 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)
120120
slot5_rate = 4 if i < 3 else 10
121-
if i >= 1:
121+
if i == 3:
122+
# This slot straddles the cap exactly (10 blocks already used of 12, this slot needs 3
123+
# more) - split at the point the daily budget runs out rather than the whole slot
124+
# flipping to the max rate (batpred#4624).
125+
slot_block_start = (minutes_start // 30) * 30
126+
split_minute = slot_block_start + 2 * 30 # 2 blocks (12 - 10) remain in the daily budget
127+
# Full precision to match production - only cost is rounded (batpred#4644 review).
128+
low_kwh = 5.0 * (split_minute - minutes_start) / 60
129+
high_kwh = 5.0 - low_kwh
130+
for target in (expected_slots5, expected_slots8):
131+
target.append({"start": minutes_start, "end": split_minute, "kwh": low_kwh, "average": 4, "cost": dp2(4 * low_kwh), "soc": 10, "octopus": True})
132+
target.append({"start": split_minute, "end": minutes_end, "kwh": high_kwh, "average": 10, "cost": dp2(10 * high_kwh), "soc": 10, "octopus": True})
133+
elif i >= 1:
122134
expected_slots5.append({"start": minutes_start, "end": minutes_end, "kwh": 5.0, "average": slot5_rate, "cost": slot5_rate * 5.0, "soc": 10, "octopus": True})
123135
expected_slots8.append({"start": minutes_start, "end": minutes_end, "kwh": 5.0, "average": slot5_rate, "cost": slot5_rate * 5.0, "soc": 10, "octopus": True})
124136
else:
@@ -206,6 +218,12 @@ def run_load_octopus_slots_tests(my_predbat):
206218
# it around a fully-contained earlier slot.
207219
print("**** Checking containment overlap (completed dispatch inside planned dispatch) ****")
208220
saved_minutes_now = my_predbat.minutes_now
221+
# This test is about overlap/containment handling specifically, not the daily low-rate block
222+
# cap (batpred#4624's split logic) - the 540-960 remainder is 14 blocks, which would otherwise
223+
# get split again by the still-active octopus_slot_max=12 from earlier in this test, coupling
224+
# two independent behaviours together. Lift the cap for just this check.
225+
saved_octopus_slot_max = my_predbat.args.get("octopus_slot_max")
226+
my_predbat.args["octopus_slot_max"] = 999
209227
containment_now = midnight_utc + timedelta(hours=10, minutes=37)
210228
my_predbat.minutes_now = int((containment_now - midnight_utc).total_seconds() / 60)
211229

@@ -242,6 +260,10 @@ def run_load_octopus_slots_tests(my_predbat):
242260
failed = True
243261

244262
my_predbat.minutes_now = saved_minutes_now
263+
if saved_octopus_slot_max is None:
264+
my_predbat.args.pop("octopus_slot_max", None)
265+
else:
266+
my_predbat.args["octopus_slot_max"] = saved_octopus_slot_max
245267

246268
if failed:
247269
return failed

0 commit comments

Comments
 (0)