Skip to content

Commit accbf99

Browse files
CIclaude
andcommitted
fix(dynamic-load): address #5229 review round 3
- compare.py runs (save=False): the rates read the saved cancellation while the slots followed a decision made fresh in that run. So a car that had started or stopped charging since the last live cycle was priced one way and planned the other. The check now records this run's decision in dynamic_load_car_effective, and rate_add_io_slots() and the feed strip read that instead. - An install with no load history (load_forecast only, where load_last_period is hard-coded to 0) counted as low load, so with car_energy_reported_load on every dispatch was cancelled 10 minutes in. No load data is now no evidence. - An unknown/unavailable reading left the grace timer running, so off -> unavailable -> off cancelled without a fresh grace. No evidence now restarts it. - A cancelled slot kept its cost. The cost is now zeroed, and publish_car_plan() includes kwh_cancelled in the planned attribute, so the entity attributes say why kWh is 0. - The "will never be trusted" warning now lists only cars with an octopus_intelligent_slot of their own, not every car without a sensor. - The feed strip re-strips the same minutes every cycle. It now logs only when the number stripped changes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 7f5a751 commit accbf99

5 files changed

Lines changed: 116 additions & 9 deletions

File tree

‎apps/predbat/octopus.py‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3604,7 +3604,7 @@ def rate_add_io_slots(self, car_n, rates, octopus_slots):
36043604
# Dynamic load has seen this car in its slot but not charging: none of its dispatches from now
36053605
# on get the cheap rate (see dynamic_load_car_check()). Elapsed minutes keep theirs - they record
36063606
# what the tariff charged, and today's cost is built from them.
3607-
car_cancelled = self.dynamic_load_car_cancelled.get(car_n, False)
3607+
car_cancelled = self.dynamic_load_car_effective.get(car_n, False)
36083608

36093609
if octopus_slots:
36103610
# Add in IO slots
@@ -3710,8 +3710,9 @@ def dynamic_load_car_strip_feed_rates(self, rates):
37103710
cancelled car's dispatch and not covered by a dispatch of any car still trusted - the rates are
37113711
shared by every car.
37123712
"""
3713-
cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_cancelled.get(car_n, False)]
3713+
cancelled_cars = [car_n for car_n in range(self.num_cars) if self.dynamic_load_car_effective.get(car_n, False)]
37143714
if not cancelled_cars or not self.io_adjusted:
3715+
self.dynamic_load_car_stripped = 0
37153716
return rates
37163717

37173718
# Rounded out to whole 30 minute rate periods: the feed marks and prices whole periods, and
@@ -3741,8 +3742,11 @@ def dynamic_load_car_strip_feed_rates(self, rates):
37413742
rates[minute] = self.rate_max_base
37423743
del self.io_adjusted[minute]
37433744
stripped += 1
3744-
if stripped:
3745+
# The feed is re-read every cycle, so the same minutes are stripped again each time - log only
3746+
# when that changes, not every 5 minutes for the length of a cancellation
3747+
if stripped and stripped != self.dynamic_load_car_stripped:
37453748
self.log("Dynamic load: removed the Intelligent dispatch rate from {} minutes of cars {} which are not charging".format(stripped, cancelled_cars))
3749+
self.dynamic_load_car_stripped = stripped
37463750
return rates
37473751

37483752
def fetch_octopus_rates(self, entity_id, adjust_key=None):

‎apps/predbat/output.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,8 @@ def publish_car_plan(self):
193193
show["kwh"] = kwh
194194
show["average"] = average
195195
show["cost"] = cost
196+
if window.get("kwh_cancelled"):
197+
show["kwh_cancelled"] = dp2(window["kwh_cancelled"])
196198
total_cost += cost
197199
total_kwh += kwh
198200
plan.append(show)

‎apps/predbat/plan.py‎

Lines changed: 22 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -350,7 +350,9 @@ def dynamic_load_car_evidence(self, car_n):
350350
charging = bool(raw)
351351
return (not charging), DYNAMIC_LOAD_CAR_SENSOR_MINUTES
352352
if self.car_energy_reported_load:
353-
if self.minutes_now <= 5:
353+
# No load history (e.g. a load_forecast-only install, where load_last_period is a hard-coded
354+
# 0) is not low load - it is no reading at all
355+
if self.minutes_now <= 5 or not self.load_minutes:
354356
return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES
355357
return self.dynamic_load_classify() == "low", DYNAMIC_LOAD_CAR_LOAD_MINUTES
356358
return None, DYNAMIC_LOAD_CAR_LOAD_MINUTES
@@ -426,6 +428,10 @@ def dynamic_load_car_target(self, car_n, minute, now, record=True):
426428
since = self.dynamic_load_car_since.setdefault(car_n, now) if record else self.dynamic_load_car_since.get(car_n)
427429
if since is not None and (now - since).total_seconds() >= grace_minutes * 60:
428430
return True
431+
elif record:
432+
# No evidence: the car may have been charging through it, so a "not charging" reading after
433+
# it starts a fresh grace period rather than counting the unknown stretch towards the old one
434+
self.dynamic_load_car_since.pop(car_n, None)
429435
return cancelled
430436

431437
def dynamic_load_car_refresh_sensors(self):
@@ -457,7 +463,13 @@ def dynamic_load_car_check_config(self):
457463

458464
cars = []
459465
if not self.octopus_intelligent_trust_slots and self.octopus_intelligent_charging and not self.car_energy_reported_load:
460-
cars = [car_n for car_n in range(self.num_cars) if car_n not in self.dynamic_load_car_sensors]
466+
# Only cars on Octopus Intelligent (with a slot sensor of their own, as fetch_sensor_data_cars()
467+
# reads them) have Intelligent slots to distrust
468+
iog_entities = self.get_arg("octopus_intelligent_slot", indirect=False)
469+
if iog_entities and not isinstance(iog_entities, list):
470+
iog_entities = [iog_entities]
471+
iog_entities = iog_entities or []
472+
cars = [car_n for car_n in range(self.num_cars) if car_n < len(iog_entities) and iog_entities[car_n] and car_n not in self.dynamic_load_car_sensors]
461473
if cars != self.dynamic_load_car_warned:
462474
if cars:
463475
self.log("Warn: octopus_intelligent_trust_slots is Off but car(s) {} have no car_charging_now sensor and are outside the CT clamp, so their Intelligent slots will never be trusted".format(cars))
@@ -486,6 +498,9 @@ def dynamic_load_car_check(self, save=True):
486498
Returns True when a car's cancellation changed, so the plan is recomputed.
487499
"""
488500
changed = False
501+
# This run's decision, which the rates read - in a compare.py run (save False) it can differ from
502+
# the saved state, and the rates must follow the same decision as the slots
503+
self.dynamic_load_car_effective = {}
489504
for car_n in range(self.num_cars):
490505
cancelled = self.dynamic_load_car_target(car_n, self.minutes_now, self.now_utc_real, record=save)
491506
if save:
@@ -500,13 +515,18 @@ def dynamic_load_car_check(self, save=True):
500515
reason = "slots resumed"
501516
self.log("Dynamic load: car {} {}".format(car_n, reason))
502517
self.dynamic_load_car_cancelled[car_n] = cancelled
518+
self.dynamic_load_car_effective[car_n] = cancelled
503519

504520
if cancelled and car_n < len(self.car_charging_slots):
505521
for slot in self.car_charging_slots[car_n]:
506522
if slot["end"] > self.minutes_now:
507523
if slot.get("kwh", 0) > 0:
508524
slot["kwh_cancelled"] = slot["kwh"]
509525
slot["kwh"] = 0
526+
# No cost for energy the plan no longer counts, so the published car plan stays
527+
# self-consistent
528+
if "cost" in slot:
529+
slot["cost"] = 0
510530
return changed
511531

512532
def dynamic_load_car_poll(self, now=None):

‎apps/predbat/predbat.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -550,6 +550,8 @@ def reset(self):
550550
self.dynamic_load_car_warned = []
551551
self.dynamic_load_car_warned_iog_off = False
552552
self.dynamic_load_car_sensors = {}
553+
self.dynamic_load_car_effective = {}
554+
self.dynamic_load_car_stripped = 0
553555
self.battery_capacity_nominal = False
554556
self.battery_scaling_auto = False
555557
self.releases = {}

‎apps/predbat/tests/test_dynamic_load_car.py‎

Lines changed: 83 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,9 @@
4747
"dynamic_load_car_sensors",
4848
"dynamic_load_car_warned",
4949
"dynamic_load_car_warned_iog_off",
50+
"dynamic_load_car_effective",
51+
"dynamic_load_car_stripped",
52+
"load_minutes",
5053
"octopus_intelligent_trust_slots",
5154
)
5255

@@ -103,8 +106,10 @@ def _reset(my_predbat):
103106
my_predbat.load_last_period = 3.0
104107
my_predbat.dynamic_load_car_since = {}
105108
my_predbat.dynamic_load_car_cancelled = {}
109+
my_predbat.dynamic_load_car_effective = {}
106110
my_predbat.update_pending = False
107111
my_predbat.octopus_intelligent_trust_slots = True
112+
my_predbat.load_minutes = {0: 10.0, 5: 9.9}
108113

109114

110115
def _kwh(my_predbat):
@@ -176,6 +181,20 @@ def _run(my_predbat):
176181
changed = _cycle(my_predbat, 860)
177182
failed |= _check("t4 unknown keeps cancellation", (not changed) and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat)))
178183

184+
# An unknown stretch is no evidence either way: the car may have been charging through it, so a
185+
# "not charging" reading after it starts a fresh grace period rather than resuming the old one
186+
print("Test 4c: an unavailable reading restarts the grace clock")
187+
_reset(my_predbat)
188+
_sensor(my_predbat, "off")
189+
_cycle(my_predbat, 840)
190+
_sensor(my_predbat, "unavailable")
191+
_cycle(my_predbat, 841)
192+
_sensor(my_predbat, "off")
193+
changed = _cycle(my_predbat, 842, 30)
194+
failed |= _check("t4c not cancelled on the old sighting", (not changed) and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat)))
195+
changed = _cycle(my_predbat, 844, 30)
196+
failed |= _check("t4c cancelled after a fresh grace", changed and _kwh(my_predbat) == [0, 0], "changed {} kwh {}".format(changed, _kwh(my_predbat)))
197+
179198
# A static literal is not a sensor, and without the CT clamp there is no load evidence either
180199
# A configured entity HA does not have (deleted, renamed, integration reloading) resolves to the
181200
# read's default - that must be no evidence, not a "not charging" reading (#5229 review)
@@ -212,6 +231,18 @@ def _run(my_predbat):
212231
changed = _cycle(my_predbat, 855)
213232
failed |= _check("t6 load back up resumes", changed and _kwh(my_predbat) == [3.5, 3.5], "changed {} kwh {}".format(changed, _kwh(my_predbat)))
214233

234+
# A load_forecast-only install has no load_today history and a hard-coded load_last_period of 0,
235+
# which is not evidence of anything (#5229 review)
236+
print("Test 6c: no load history is not low-load evidence")
237+
_reset(my_predbat)
238+
_sensor(my_predbat, None)
239+
my_predbat.car_energy_reported_load = True
240+
my_predbat.load_minutes = {}
241+
my_predbat.load_last_period = 0
242+
_cycle(my_predbat, 840)
243+
_cycle(my_predbat, 855)
244+
failed |= _check("t6c no load data", _kwh(my_predbat) == [3.5, 3.5] and my_predbat.dynamic_load_car_since.get(0) is None, "kwh {}".format(_kwh(my_predbat)))
245+
215246
print("Test 6b: the load test is not used just after midnight, when load_today resets")
216247
_reset(my_predbat)
217248
_sensor(my_predbat, None)
@@ -267,6 +298,13 @@ def _run(my_predbat):
267298
_cycle(my_predbat, 875, save=False)
268299
failed |= _check("t10 not applied outside the slot", _kwh(my_predbat) == [3.5, 3.5], "kwh {}".format(_kwh(my_predbat)))
269300
failed |= _check("t10 saved state untouched", my_predbat.dynamic_load_car_cancelled.get(0, False), "cancelled {}".format(my_predbat.dynamic_load_car_cancelled))
301+
# ...and the rates in that comparison run follow the same decision as its slots (#5229 review)
302+
_sensor(my_predbat, "on")
303+
_cycle(my_predbat, 851, save=False)
304+
failed |= _check("t10 comparison rates follow the run's own decision", my_predbat.dynamic_load_car_effective.get(0) is False, "effective {}".format(my_predbat.dynamic_load_car_effective))
305+
_sensor(my_predbat, "off")
306+
_cycle(my_predbat, 852, save=False)
307+
failed |= _check("t10 comparison rates cancelled with its slots", my_predbat.dynamic_load_car_effective.get(0) is True and _kwh(my_predbat) == [0, 0], "effective {} kwh {}".format(my_predbat.dynamic_load_car_effective, _kwh(my_predbat)))
270308

271309
# Predbat-led charging is a different mechanism: Predbat itself decides when the car charges, and
272310
# its slot sensor drives the charger - cancelling it would stop the very charge it is waiting to
@@ -349,13 +387,13 @@ def _run_rates(my_predbat):
349387
my_predbat.args["octopus_slot_max"] = 48
350388
my_predbat.rate_min_base = 4.0
351389
my_predbat.rate_max_base = 30.0
352-
my_predbat.dynamic_load_car_cancelled = {0: True}
390+
my_predbat.dynamic_load_car_effective = {0: True}
353391
rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)}
354392
rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 810, 900)])
355393
failed |= _check("t12 past minute keeps its discount", rates[820] == 4.0, "rate {}".format(rates[820]))
356394
failed |= _check("t12 future minute not discounted", rates[840] == 10.0 and rates[899] == 10.0, "rates {} {}".format(rates[840], rates[899]))
357395

358-
my_predbat.dynamic_load_car_cancelled = {}
396+
my_predbat.dynamic_load_car_effective = {}
359397
rates = {minute: 10.0 for minute in range(0, 2 * 24 * 60)}
360398
rates = my_predbat.rate_add_io_slots(0, rates, [_dispatch(my_predbat, 810, 900)])
361399
failed |= _check("t12 not cancelled is discounted", rates[840] == 4.0, "rate {}".format(rates[840]))
@@ -365,7 +403,7 @@ def _run_rates(my_predbat):
365403
_at(my_predbat, 840)
366404
my_predbat.num_cars = 2
367405
my_predbat.rate_max_base = 30.0
368-
my_predbat.dynamic_load_car_cancelled = {0: True}
406+
my_predbat.dynamic_load_car_effective = {0: True}
369407
my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]]
370408
rates = {minute: 25.0 for minute in range(0, 2 * 24 * 60)}
371409
my_predbat.io_adjusted = {}
@@ -416,7 +454,22 @@ def _run_rates(my_predbat):
416454
my_predbat.num_cars = 2
417455
my_predbat.octopus_slots = [[_dispatch(my_predbat, 810, 930), _dispatch(my_predbat, 1410, 1440)], [_dispatch(my_predbat, 900, 930)]]
418456

419-
my_predbat.dynamic_load_car_cancelled = {}
457+
print("Test 13d: the feed strip logs when what it strips changes, not every cycle")
458+
my_predbat.dynamic_load_car_effective = {0: True}
459+
my_predbat.dynamic_load_car_stripped = 0
460+
logged = []
461+
saved_log = my_predbat.log
462+
my_predbat.log = lambda message, *args, **kwargs: logged.append(message)
463+
try:
464+
for _ in range(3):
465+
my_predbat.io_adjusted = {minute: True for minute in range(840, 870)}
466+
my_predbat.dynamic_load_car_strip_feed_rates({minute: 7.0 for minute in range(0, 2 * 24 * 60)})
467+
finally:
468+
my_predbat.log = saved_log
469+
strip_logs = [message for message in logged if "removed the Intelligent dispatch rate" in message]
470+
failed |= _check("t13d logged once", len(strip_logs) == 1, "logged {}".format(strip_logs))
471+
472+
my_predbat.dynamic_load_car_effective = {}
420473
rates[850] = 7.0
421474
my_predbat.io_adjusted[850] = True
422475
rates = my_predbat.dynamic_load_car_strip_feed_rates(rates)
@@ -612,14 +665,27 @@ def _run_untrusted(my_predbat):
612665
my_predbat.octopus_intelligent_trust_slots = False
613666
my_predbat.car_energy_reported_load = False
614667
my_predbat.dynamic_load_car_warned = []
668+
had_slot_sensor_27 = "octopus_intelligent_slot" in my_predbat.args
669+
saved_slot_sensor_27 = my_predbat.args.get("octopus_intelligent_slot")
670+
my_predbat.args["octopus_intelligent_slot"] = "binary_sensor.octopus_intelligent_slot_test"
615671
cars = my_predbat.dynamic_load_car_check_config()
616672
failed |= _check("t27 no evidence warned", cars == [0] and my_predbat.dynamic_load_car_warned == [0], "cars {}".format(cars))
617673
my_predbat.car_energy_reported_load = True
618674
failed |= _check("t27 CT clamp is evidence", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned))
619675
my_predbat.car_energy_reported_load = False
620676
_sensor(my_predbat, "off")
621677
failed |= _check("t27 sensor is evidence", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned))
678+
# A second car with no Intelligent slot sensor of its own has no Intelligent slots to distrust
679+
print("Test 27c: only cars on Octopus Intelligent are warned about")
622680
_sensor(my_predbat, None)
681+
my_predbat.num_cars = 2
682+
my_predbat.dynamic_load_car_refresh_sensors()
683+
failed |= _check("t27c mixed install", my_predbat.dynamic_load_car_check_config() == [0], "cars {}".format(my_predbat.dynamic_load_car_warned))
684+
my_predbat.num_cars = 1
685+
if had_slot_sensor_27:
686+
my_predbat.args["octopus_intelligent_slot"] = saved_slot_sensor_27
687+
else:
688+
my_predbat.args.pop("octopus_intelligent_slot", None)
623689
my_predbat.octopus_intelligent_trust_slots = True
624690
failed |= _check("t27 trust On never warns", my_predbat.dynamic_load_car_check_config() == [], "cars {}".format(my_predbat.dynamic_load_car_warned))
625691

@@ -672,6 +738,19 @@ def _run_kwh_cancelled(my_predbat):
672738
_sensor(my_predbat, "on")
673739
_cycle(my_predbat, 850, slots=slots)
674740
failed |= _check("t26 gone once resumed", _cancelled(my_predbat) == [None, None], "cancelled {}".format(_cancelled(my_predbat)))
741+
742+
# The published car plan stays self-consistent for a cancelled slot: no cost for energy the plan no
743+
# longer counts, and the cancelled kWh alongside so the attributes say why kWh is 0 (#5229 review)
744+
print("Test 26b: a cancelled slot is published with no cost and its kwh_cancelled")
745+
_sensor(my_predbat, "off")
746+
priced = [{"start": 840, "end": 870, "kwh": 3.5, "average": 7.0, "cost": 24.5, "soc": 10.0, "octopus": True}]
747+
_cycle(my_predbat, 840, slots=priced)
748+
_cycle(my_predbat, 845, slots=priced)
749+
failed |= _check("t26b cost zeroed", my_predbat.car_charging_slots[0][0].get("cost") == 0, "slot {}".format(my_predbat.car_charging_slots[0][0]))
750+
my_predbat.publish_car_plan()
751+
planned = my_predbat.get_state_wrapper("binary_sensor." + my_predbat.prefix + "_car_charging_slot", attribute="planned") or []
752+
first = planned[0] if planned else {}
753+
failed |= _check("t26b published", first.get("kwh") == 0 and first.get("cost") == 0 and first.get("kwh_cancelled") == 3.5, "planned {}".format(planned))
675754
return failed
676755

677756

0 commit comments

Comments
 (0)