Skip to content

Commit 4e92beb

Browse files
fix(octopus): split a slot around a fully-contained overlap instead of losing its remainder (#4497)
fetch.py merges the HA Octopus Energy integration's completed_dispatches ahead of planned_dispatches unconditionally. load_octopus_slots()'s overlap dedup processes slots in that order, but could only ever trim one edge of a slot against an already-decoded one, or drop it entirely if fully contained - it had no path to split a slot that fully CONTAINS an already-decoded one. A short completed historical interval sitting inside a much longer still-active planned interval therefore truncated the planned interval down to whatever sliver preceded the completed interval, silently discarding its entire future remainder (e.g. a planned 07:59-16:00 dispatch containing a 08:00-09:00 completed interval collapsed to 07:59-08:00, already in the past). Reported with an exact repro and code trace by @cparmar. The overlap resolution now subtracts each already-decoded slot's span from the new slot's remaining segments, which can produce zero, one, or two pieces - the two-piece case is the one that was missing. kwh is only rescaled proportionally when a slot is actually split into multiple pieces; the existing single-edge-trim behaviour (and its energy value) is unchanged, preserving the existing hard-coded regression expectations in test_octopus_slots.py.
1 parent 4bb7841 commit 4e92beb

2 files changed

Lines changed: 79 additions & 10 deletions

File tree

apps/predbat/octopus.py

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2565,19 +2565,40 @@ def load_octopus_slots(self, car_n, octopus_slots, octopus_intelligent_consider_
25652565
kwh = remaining_minutes * self.car_charging_rate[car_n] / 60.0
25662566
start_minutes = self.minutes_now # align span with the synthesised kwh so downstream rate calculations are consistent
25672567
if kwh > 0:
2568-
# Don't add overlapping slots, bug in Octopus API means that sometimes slots overlap
2568+
# Don't add overlapping slots, bug in Octopus API means that sometimes slots overlap.
2569+
# Subtract every already-decoded slot's span from this slot's remaining segments -
2570+
# a slot can be trimmed at the start, trimmed at the end, removed entirely (it's
2571+
# fully covered by an existing slot), or - the case that was previously missing
2572+
# here (issue #4497) - it can fully CONTAIN an existing slot, which must split it
2573+
# into up to two remaining segments rather than just trimming one edge, or the
2574+
# whole future remainder silently disappears. This matters because fetch.py merges
2575+
# the HA Octopus Energy integration's completed_dispatches ahead of
2576+
# planned_dispatches: a short completed historical interval commonly sits inside a
2577+
# much longer still-active planned interval.
2578+
original_span = end_minutes - start_minutes
2579+
segments = [(start_minutes, end_minutes)]
25692580
for current_slot in slots_decoded:
25702581
current_start, current_end, current_kwh, current_source, current_location = current_slot
2571-
if (start_minutes < current_end) and (end_minutes > current_start):
2572-
if start_minutes < current_start:
2573-
end_minutes = current_start
2574-
elif end_minutes > current_end:
2575-
start_minutes = current_end
2582+
remaining = []
2583+
for seg_start, seg_end in segments:
2584+
if seg_start < current_end and seg_end > current_start:
2585+
if seg_start < current_start:
2586+
remaining.append((seg_start, current_start))
2587+
if seg_end > current_end:
2588+
remaining.append((current_end, seg_end))
25762589
else:
2577-
start_minutes = end_minutes # Remove slot
2578-
# Only add the slot if it has a non-zero duration
2579-
if start_minutes != end_minutes:
2580-
slots_decoded.append((start_minutes, end_minutes, kwh, source, location))
2590+
remaining.append((seg_start, seg_end))
2591+
segments = remaining
2592+
# A single remaining segment (the common case: no overlap, or the previously-existing
2593+
# single-edge-trim behaviour) keeps its original full kwh unchanged. Only when the
2594+
# slot was actually split into multiple pieces is kwh scaled by each piece's share of
2595+
# the original span, so a genuine split never duplicates energy across the pieces.
2596+
split = len(segments) > 1
2597+
for seg_start, seg_end in segments:
2598+
if seg_start == seg_end:
2599+
continue
2600+
seg_kwh = kwh * (seg_end - seg_start) / original_span if (split and original_span > 0) else kwh
2601+
slots_decoded.append((seg_start, seg_end, seg_kwh, source, location))
25812602

25822603
# Sort slots by start time
25832604
slots_sorted = sorted(slots_decoded, key=lambda x: x[0])

apps/predbat/tests/test_octopus_slots.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,54 @@ def run_load_octopus_slots_tests(my_predbat):
194194
slot_future_zero = [{"start": future_start.strftime(TIME_FORMAT), "end": future_end.strftime(TIME_FORMAT), "charge_in_kwh": 0, "source": "null", "location": "AT_HOME"}]
195195
failed |= run_load_octopus_slot_test("zero_kwh_future", my_predbat, slot_future_zero, [], False, 0.0, 0.0, 1.0)
196196

197+
# --- containment overlap: completed dispatch inside a longer planned dispatch (#4497) ---
198+
# The HA Octopus Energy integration's completed_dispatches are merged ahead of
199+
# planned_dispatches (fetch.py). A short completed historical interval sitting inside a much
200+
# longer still-active planned interval must not truncate away the planned interval's future
201+
# remainder - the overlap dedup previously only ever trimmed one edge of a slot, never split
202+
# it around a fully-contained earlier slot.
203+
print("**** Checking containment overlap (completed dispatch inside planned dispatch) ****")
204+
saved_minutes_now = my_predbat.minutes_now
205+
containment_now = midnight_utc + timedelta(hours=10, minutes=37)
206+
my_predbat.minutes_now = int((containment_now - midnight_utc).total_seconds() / 60)
207+
208+
containment_slots = [
209+
# completed_dispatches (merged first, per fetch.py)
210+
{"start": (midnight_utc + timedelta(hours=8)).strftime(TIME_FORMAT), "end": (midnight_utc + timedelta(hours=8, minutes=30)).strftime(TIME_FORMAT), "charge_in_kwh": 5.45, "source": "null", "location": "AT_HOME"},
211+
{"start": (midnight_utc + timedelta(hours=8, minutes=30)).strftime(TIME_FORMAT), "end": (midnight_utc + timedelta(hours=9)).strftime(TIME_FORMAT), "charge_in_kwh": 5.66, "source": "null", "location": "AT_HOME"},
212+
# planned_dispatches (merged second) - the first one fully contains both completed slots above
213+
{"start": (midnight_utc + timedelta(hours=7, minutes=59)).strftime(TIME_FORMAT), "end": (midnight_utc + timedelta(hours=16)).strftime(TIME_FORMAT), "charge_in_kwh": 40.0, "source": "SMART", "location": "AT_HOME"},
214+
{"start": (midnight_utc + timedelta(hours=17, minutes=30)).strftime(TIME_FORMAT), "end": (midnight_utc + timedelta(hours=18)).strftime(TIME_FORMAT), "charge_in_kwh": 2.5, "source": "SMART", "location": "AT_HOME"},
215+
]
216+
217+
my_predbat.car_charging_soc[0] = 0.0
218+
my_predbat.car_charging_limit[0] = 0.0
219+
my_predbat.car_charging_loss = 1.0
220+
result = my_predbat.load_octopus_slots(0, containment_slots, False)
221+
222+
# The 09:00-16:00 (540-960) future remainder of the contained planned dispatch must survive
223+
if not result or result[0]["end"] != 960:
224+
print("ERROR: Future remainder of contained planned dispatch was lost - expected the earliest surviving slot to end at 960 (16:00), got {}\nSlots: {}".format(result[0]["end"] if result else None, result))
225+
failed = True
226+
else:
227+
# kwh for that remainder is the 40.0kWh original scaled by its 420-of-481-minute share
228+
# (479-960 minus the 480-540 carved out by the two completed slots) - must not have been
229+
# lost (the reported bug) nor duplicated across the split pieces
230+
expected_kwh = 40.0 * (960 - 540) / (960 - 479)
231+
if abs(result[0]["kwh"] - expected_kwh) > 0.01:
232+
print("ERROR: Future remainder kwh should be {:.2f}, got {}\nSlots: {}".format(expected_kwh, result[0]["kwh"], result))
233+
failed = True
234+
235+
# The second planned dispatch (17:30-18:00), which never overlapped anything, is untouched
236+
if len(result) < 2 or result[1]["start"] != 1050 or result[1]["end"] != 1080 or result[1]["kwh"] != 2.5:
237+
print("ERROR: Non-overlapping planned dispatch should be unchanged (1050-1080, 2.5kWh), got {}\nSlots: {}".format(result[1] if len(result) > 1 else None, result))
238+
failed = True
239+
240+
my_predbat.minutes_now = saved_minutes_now
241+
242+
if failed:
243+
return failed
244+
197245
print("**** Checking car_charge_slot_kwh ****")
198246
my_predbat.car_charging_slots[0] = expected_slots5
199247
my_predbat.num_cars = 1

0 commit comments

Comments
 (0)