Skip to content

Commit 707962d

Browse files
fix(octopus): don't clear the car_charging_now streak on unknown, fix stale-anchor re-lapse
Three findings from Copilot's 2026-09-17 18:28 review on #5110, none addressed by the prior midnight-rollover push (ba63fd9): - get_car_charging_planned() treated any non-matching car_charging_now value as a confirmed stop, including a transient HA "unknown"/ "unavailable" reading (e.g. around a restart). That could drop an active streak's confirmation even though the car may have kept charging throughout. Now uses the existing get_car_charging_now_tristate() and only ends the streak on a real negative (tristate False); an unknown reading falls through untouched, same as a guard-failing True. - When a fresh confirming read re-anchored the streak in the same cycle the *previous* anchor had already exceeded the rollover cap, the cap-exceeded branch still ran against that now-superseded anchor and immediately cleared the one this cycle just set - discarding the fresh confirmation it was supposed to record. The lapse branch now only fires when this cycle's own reading didn't already re-anchor. - Added exclude_dynamic_io_slots() test coverage (fetch.py:1255 finding): it was never called by any test, only rate_add_io_slots() directly, leaving the feed-side io_adjusted stripping path - the one that matters most on a real Intelligent tariff - unverified. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent ba63fd9 commit 707962d

3 files changed

Lines changed: 150 additions & 4 deletions

File tree

‎apps/predbat/fetch.py‎

Lines changed: 19 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2685,29 +2685,44 @@ def get_car_charging_planned(self):
26852685
# streak's original evidence rather than collapsing onto the slot just confirmed.
26862686
streak_anchor = self.car_charging_now_streak_last_read[car_n]
26872687

2688+
# A transient HA "unknown"/"unavailable" reading (tristate None) is no evidence either
2689+
# way, not a confirmed stop - ending the streak on it would drop confirmation after a
2690+
# sensor hiccup or restart even though the car may still be charging throughout. Only a
2691+
# real negative reading (tristate False) ends the streak; None falls through the same as
2692+
# a True read inside the guard window.
2693+
charging_now_tristate = self.get_car_charging_now_tristate(car_n)
2694+
26882695
if slot_reading_confirms:
26892696
self.car_charging_now_confirmed_slots[car_n].add(current_slot_start)
26902697
self.car_charging_now_streak_last_read[car_n] = current_slot_start
2691-
elif not charging_now:
2698+
elif charging_now_tristate is False:
26922699
# Clearing the anchor too skips the backfill, so nothing is confirmed on the strength
26932700
# of a streak this read just ended.
26942701
streak_anchor = None
26952702
self.car_charging_now_streak_last_read[car_n] = None
2696-
# The third case (True inside the guard window) intentionally falls through untouched.
2703+
# The remaining cases (True inside the guard window, or an unknown/unavailable reading)
2704+
# intentionally fall through untouched.
26972705
# Corollary worth knowing: if a streak has lapsed and every subsequent positive read lands
26982706
# inside the guard window, no new streak starts and "started" silently behaves as
26992707
# "none" for that car. It needs replans to have all but stopped for a whole slot, so
27002708
# it is unlikely at the ~15s poll cadence, and it fails safe (under-trusting, not over-).
27012709

27022710
# Confirm every slot from the anchor to now, so slots no replan happened to land in are
2703-
# still covered. Idempotent.
2711+
# still covered. Idempotent. streak_anchor here is deliberately the *previous* cycle's
2712+
# anchor (read above, before slot_reading_confirms could reassign it) - a fresh read this
2713+
# cycle still wants the gap since that old anchor backfilled (Test 3: resuming a streak at
2714+
# slot 4 must retroactively confirm slots 2 and 3 too). What must NOT happen is the
2715+
# cap-exceeded branch clobbering an anchor this cycle just (re-)set: a streak that had
2716+
# already lapsed past the rollover cap, followed by a fresh confirming read this cycle,
2717+
# would otherwise immediately wipe the anchor slot_reading_confirms just wrote, discarding
2718+
# the fresh confirm it was supposed to record (Copilot review on #5110).
27042719
if streak_anchor is not None:
27052720
slots_since_anchor = (current_slot_start - streak_anchor) // 30
27062721
if 0 <= slots_since_anchor <= CAR_CHARGING_NOW_STREAK_MAX_ROLLOVER_SLOTS:
27072722
# + 30 rather than + 1: range()'s stop is exclusive and a step is a whole slot.
27082723
for backfill_slot in range(streak_anchor, current_slot_start + 30, 30):
27092724
self.car_charging_now_confirmed_slots[car_n].add(backfill_slot)
2710-
else:
2725+
elif not slot_reading_confirms:
27112726
# Cap exceeded with no fresh evidence - lapse the streak, but leave slots already
27122727
# confirmed while it was valid alone.
27132728
self.car_charging_now_streak_last_read[car_n] = None

‎apps/predbat/tests/test_car_charging_now_confirmed_slots.py‎

Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -199,6 +199,38 @@ def reset_streak_state():
199199
if not failed:
200200
print("Test 7 passed - streak correctly lapses beyond the rollover cap with no fresh read")
201201

202+
# Test 7b: a fresh confirming read must actually stick even when the *previous* cycle's anchor
203+
# (still on record when this cycle starts) is already more than the rollover cap behind the new
204+
# slot - it must not be wiped in the same cycle by the rollover-cap check comparing the fresh
205+
# anchor against that now-superseded old one (Copilot review on #5110). Distinct from Test 7:
206+
# there the lapse and the eventual read are two separate cycles; here both the stale-old-anchor
207+
# comparison and the fresh confirming read happen in the SAME get_car_charging_planned() call.
208+
print("*** Test 7b: a fresh read whose own old anchor is already past the cap still re-anchors ***")
209+
reset_streak_state()
210+
my_predbat.minutes_now = 8 * 60 # 08:00 - slot 480, a genuine positive read to establish an anchor
211+
my_predbat.args["car_charging_now"] = "yes"
212+
my_predbat.get_car_charging_planned()
213+
old_anchor_7b = my_predbat.car_charging_now_streak_last_read[0]
214+
if old_anchor_7b != 480:
215+
print("ERROR: setup assumption broken - expected an anchor at slot 480, got {}".format(old_anchor_7b))
216+
failed = True
217+
218+
# Jump straight to a slot comfortably beyond the rollover cap from slot 480, in one single call -
219+
# the old anchor (480) is now stale enough that, uncorrected, the cap check would clear it, but
220+
# this same cycle's own reading independently confirms and re-anchors the current slot.
221+
fresh_slot = old_anchor_7b + (CAR_CHARGING_NOW_STREAK_MAX_ROLLOVER_SLOTS + 5) * 30
222+
my_predbat.minutes_now = fresh_slot # start of the slot - plenty of time left, passes the confirm guard
223+
my_predbat.get_car_charging_planned() # car_charging_now is still "yes"
224+
225+
if fresh_slot not in my_predbat.car_charging_now_confirmed_slots[0]:
226+
print("ERROR: slot {} should be confirmed by this cycle's own fresh positive read".format(fresh_slot))
227+
failed = True
228+
if my_predbat.car_charging_now_streak_last_read[0] != fresh_slot:
229+
print("ERROR: the fresh read should have re-anchored the streak at slot {}, got {}".format(fresh_slot, my_predbat.car_charging_now_streak_last_read[0]))
230+
failed = True
231+
if not failed:
232+
print("Test 7b passed - a fresh read survives even when its own old anchor was already past the cap")
233+
202234
# Test 8: old slots (more than a day behind the current slot) are pruned so the set cannot grow
203235
# unbounded on a long-running install.
204236
print("*** Test 8: stale confirmed slots are pruned ***")
@@ -296,6 +328,41 @@ def reset_streak_state():
296328
else:
297329
print("Test 11 passed")
298330

331+
# Test 12: a transient HA "unknown"/"unavailable" reading is not evidence of a stop - it must
332+
# fall through the same as a guard-failing True read, leaving an active streak intact, rather
333+
# than being treated as a negative edge (Copilot review on #5110). A real sensor genuinely goes
334+
# "unknown" for a few seconds around an HA restart while the car may still be charging throughout.
335+
print("*** Test 12: an unknown/unavailable reading does not end an active streak ***")
336+
reset_streak_state()
337+
my_predbat.minutes_now = 9 * 60 # 09:00 - slot 540, a genuine positive read starts the streak
338+
my_predbat.args["car_charging_now"] = "yes"
339+
my_predbat.get_car_charging_planned()
340+
if my_predbat.car_charging_now_streak_last_read[0] != 540:
341+
print("ERROR: setup assumption broken - expected an anchor at slot 540, got {}".format(my_predbat.car_charging_now_streak_last_read[0]))
342+
failed = True
343+
344+
my_predbat.minutes_now = 9 * 60 + 30 # 09:30 - next slot (570), sensor now reporting "unknown"
345+
my_predbat.args["car_charging_now"] = "unknown"
346+
my_predbat.get_car_charging_planned()
347+
348+
if my_predbat.car_charging_now_streak_last_read[0] != 540:
349+
print("ERROR: an unknown/unavailable reading should not have moved or cleared the streak anchor, got {}".format(my_predbat.car_charging_now_streak_last_read[0]))
350+
failed = True
351+
if 570 not in my_predbat.car_charging_now_confirmed_slots[0]:
352+
print("ERROR: slot 570 should still have been backfilled by the still-active streak despite the unknown reading")
353+
failed = True
354+
355+
# A genuine negative edge afterwards must still end the streak normally - "unknown" only
356+
# suspends judgement, it does not disable the mechanism.
357+
my_predbat.minutes_now = 10 * 60 # 10:00 - slot 600
358+
my_predbat.args["car_charging_now"] = "no"
359+
my_predbat.get_car_charging_planned()
360+
if my_predbat.car_charging_now_streak_last_read[0] is not None:
361+
print("ERROR: a genuine False reading after an unknown one should still end the streak, got anchor {}".format(my_predbat.car_charging_now_streak_last_read[0]))
362+
failed = True
363+
if not failed:
364+
print("Test 12 passed - unknown/unavailable readings are ignored, real negative edges still end the streak")
365+
299366
if not failed:
300367
print("**** All car_charging_now_confirmed_slots tests PASSED ****")
301368
else:

‎apps/predbat/tests/test_rate_add_io_slots.py‎

Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -764,6 +764,70 @@ def run_rate_add_io_slots_tests(my_predbat):
764764
failed |= run_rate_add_io_slots_test("test41_sub_30min_interval_slot_decides_its_own_admission", my_predbat, slots_41, True, 12, expected_rates_41, confirmed=False)
765765
my_predbat.plan_interval_minutes = 30
766766

767+
print("\n**** Test 42: exclude_dynamic_io_slots strips a feed-side discount rate_add_io_slots() never trusted ****")
768+
# rate_add_io_slots() only overlays a discount for minutes it explicitly adds to trusted_dynamic_
769+
# minutes. A real Intelligent tariff can hand back an already-discounted rate straight from the
770+
# feed for a future out-of-window minute (flagged via io_adjusted) that rate_add_io_slots() never
771+
# even visited this cycle - exclude_dynamic_io_slots() is what has to catch and undo that one.
772+
# "planned" is a deliberate no-op for this function (old pre-#4516 behaviour), so use "none" -
773+
# the strictest level, and the only one that actually walks io_adjusted below.
774+
my_predbat.trust_future_dynamic_iog_slots = "none"
775+
saved_io_adjusted_42 = dict(my_predbat.io_adjusted)
776+
my_predbat.trusted_dynamic_minutes = set() # nothing trusted this cycle
777+
rates_42 = {minute: 10.0 for minute in range(-96 * 60, max(my_predbat.forecast_minutes, 3 * 24 * 60))}
778+
for minute in range(840, 870): # 14:00-14:30, well outside the 23:30-05:30 fixed window
779+
rates_42[minute] = 4.0
780+
my_predbat.io_adjusted[minute] = True
781+
result_rates_42 = my_predbat.exclude_dynamic_io_slots(rates_42)
782+
for minute in range(840, 870):
783+
if result_rates_42.get(minute) != my_predbat.rate_max_base:
784+
print("ERROR: untrusted feed-discounted minute {} should be restored to rate_max_base ({}), got {}".format(minute, my_predbat.rate_max_base, result_rates_42.get(minute)))
785+
failed = True
786+
if minute in my_predbat.io_adjusted:
787+
print("ERROR: untrusted feed-discounted minute {} should have its io_adjusted marker cleared".format(minute))
788+
failed = True
789+
my_predbat.io_adjusted = saved_io_adjusted_42
790+
791+
print("\n**** Test 43: exclude_dynamic_io_slots preserves a feed-side discount rate_add_io_slots() did trust ****")
792+
my_predbat.trust_future_dynamic_iog_slots = "none"
793+
saved_io_adjusted_43 = dict(my_predbat.io_adjusted)
794+
rates_43 = {minute: 10.0 for minute in range(-96 * 60, max(my_predbat.forecast_minutes, 3 * 24 * 60))}
795+
for minute in range(840, 870):
796+
rates_43[minute] = 4.0
797+
my_predbat.io_adjusted[minute] = True
798+
my_predbat.trusted_dynamic_minutes = set(range(840, 870)) # this cycle already trusted these minutes
799+
result_rates_43 = my_predbat.exclude_dynamic_io_slots(rates_43)
800+
for minute in range(840, 870):
801+
if result_rates_43.get(minute) != 4.0:
802+
print("ERROR: trusted feed-discounted minute {} should keep its 4.0 rate, got {}".format(minute, result_rates_43.get(minute)))
803+
failed = True
804+
if minute not in my_predbat.io_adjusted:
805+
print("ERROR: trusted feed-discounted minute {} should keep its io_adjusted marker".format(minute))
806+
failed = True
807+
my_predbat.io_adjusted = saved_io_adjusted_43
808+
my_predbat.trusted_dynamic_minutes = set()
809+
810+
print("\n**** Test 44: exclude_dynamic_io_slots leaves a fixed-window minute alone even when untrusted ****")
811+
# 02:00 is inside the guaranteed-cheap 23:30-05:30 window - that discount comes from the tariff
812+
# itself, not a reclaimable dispatch, so it must survive regardless of trust_future_dynamic_iog_slots.
813+
my_predbat.trust_future_dynamic_iog_slots = "none"
814+
saved_io_adjusted_44 = dict(my_predbat.io_adjusted)
815+
rates_44 = {minute: 10.0 for minute in range(-96 * 60, max(my_predbat.forecast_minutes, 3 * 24 * 60))}
816+
fixed_window_minute_44 = 26 * 60 # 02:00 the following day relative to midnight_utc_26, well ahead of minutes_now=600
817+
for minute in range(fixed_window_minute_44, fixed_window_minute_44 + 30):
818+
rates_44[minute] = 4.0
819+
my_predbat.io_adjusted[minute] = True
820+
my_predbat.trusted_dynamic_minutes = set()
821+
result_rates_44 = my_predbat.exclude_dynamic_io_slots(rates_44)
822+
for minute in range(fixed_window_minute_44, fixed_window_minute_44 + 30):
823+
if result_rates_44.get(minute) != 4.0:
824+
print("ERROR: fixed-window minute {} should keep its 4.0 rate regardless of trust, got {}".format(minute, result_rates_44.get(minute)))
825+
failed = True
826+
if minute not in my_predbat.io_adjusted:
827+
print("ERROR: fixed-window minute {} should keep its io_adjusted marker".format(minute))
828+
failed = True
829+
my_predbat.io_adjusted = saved_io_adjusted_44
830+
767831
# Restore original state
768832
my_predbat.trust_future_dynamic_iog_slots = saved_trust_dynamic
769833
my_predbat.octopus_intelligent_limit_future_slots = saved_limit_future_slots

0 commit comments

Comments
 (0)