Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
122 changes: 72 additions & 50 deletions apps/predbat/octopus.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
24 changes: 23 additions & 1 deletion apps/predbat/tests/test_octopus_slots.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
Loading