Skip to content

Commit f92f8fa

Browse files
fix(compare): skip a compare_list entry with no id instead of crashing (#5472)
Compare.run_all() indexed tariff["id"] directly, so a compare_list entry missing its id line raised KeyError every 5-minute cycle - and because the crash skipped predbat.py's compare_active reset, the comparison (and the crash) kept re-triggering until the config was fixed. The id lookup and its warning are now in their own get_tariff_id() method, so the guard can be tested directly rather than only by inspecting run_all()'s source text (code review for #5472 found the source-inspection test alone would pass a guard that checked the wrong field, e.g. tariff.get("name")). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
1 parent d7d8c60 commit f92f8fa

3 files changed

Lines changed: 69 additions & 6 deletions

File tree

‎apps/predbat/compare.py‎

Lines changed: 14 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -592,6 +592,14 @@ def recompute_car_charging(self, car_charging_slots):
592592
if my_predbat.car_charging_planned[car_n] and my_predbat.car_charging_exclusive[car_n]:
593593
break
594594

595+
def get_tariff_id(self, tariff):
596+
"""Return a compare_list entry's id, or None and a warning if it has none (#5472)."""
597+
tariff_id = tariff.get("id")
598+
if not tariff_id:
599+
self.log("Warn: Compare tariff {} has no id, skipping - add an 'id:' line to this compare_list entry".format(tariff.get("name", "")))
600+
return None
601+
return tariff_id
602+
595603
def run_all(self, debug=False, fetch_sensor=True):
596604
"""
597605
Compare a comparison in prices across multiple energy tariffs and report results
@@ -672,7 +680,10 @@ def run_all(self, debug=False, fetch_sensor=True):
672680

673681
try:
674682
for tariff in compare_list:
675-
prior = results.get(tariff["id"], {})
683+
tariff_id = self.get_tariff_id(tariff)
684+
if not tariff_id:
685+
continue
686+
prior = results.get(tariff_id, {})
676687
if prior:
677688
prior_date = prior.get("date", "")[:10]
678689
if prior_date == today_date:
@@ -684,11 +695,11 @@ def run_all(self, debug=False, fetch_sensor=True):
684695
else:
685696
# First ever run for this tariff: start from actual midnight SoC
686697
start_soc = soc_midnight_fallback
687-
self.log("Compare tariff {} starting SoC: {:.2f} kWh".format(tariff.get("id", ""), start_soc))
698+
self.log("Compare tariff {} starting SoC: {:.2f} kWh".format(tariff_id, start_soc))
688699
try:
689700
result_data = self.run_single(tariff, rate_import_base, rate_export_base, end_record, debug=debug, fetch_sensor=fetch_sensor, car_charging_slots=save_car_charging_slots, start_soc=start_soc, io_adjusted_base=io_adjusted_base)
690701
if result_data is not None:
691-
results[tariff["id"]] = result_data
702+
results[tariff_id] = result_data
692703
finally:
693704
# Restore hardware settings after each tariff so overrides don't bleed into the next tariff;
694705
# in a finally so a raised run_single() can't leave compare values applied to the live instance

‎apps/predbat/predbat.py‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1430,10 +1430,13 @@ def update_pred(self, scheduled=True):
14301430

14311431
if self.comparison:
14321432
if (scheduled and self.minutes_now < RUN_EVERY) or self.get_arg("compare_active", False):
1433-
# Compare tariffs either when triggered or daily at midnight
1433+
# Compare tariffs either when triggered or daily at midnight; the reset must run even if
1434+
# run_all() raises, otherwise a crashed comparison re-triggers itself every cycle (#5472)
14341435
self.expose_config("compare_active", True)
1435-
self.comparison.run_all()
1436-
self.expose_config("compare_active", False)
1436+
try:
1437+
self.comparison.run_all()
1438+
finally:
1439+
self.expose_config("compare_active", False)
14371440
else:
14381441
# Otherwise just update HA sensors to prevent then expiring
14391442
self.comparison.publish_only()

‎apps/predbat/tests/test_compare.py‎

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -717,6 +717,55 @@ def _mock_plan_iboost_smart():
717717
else:
718718
print("PASS T26: only a tariff reusing the live import rates keeps the live dispatch markers")
719719

720+
# ------------------------------------------------------------------
721+
# T27: get_tariff_id() returns None (with a warning) for a compare_list
722+
# entry with no id, and the real id string otherwise (#5472 - run_all()
723+
# used to index tariff["id"] directly, raising KeyError every cycle; a
724+
# crash there also left compare_active stuck on, re-triggering the
725+
# comparison - and the crash - every 5-minute cycle)
726+
# ------------------------------------------------------------------
727+
cmp, pb = _make_compare()
728+
logged = []
729+
cmp.log = lambda msg: logged.append(msg)
730+
731+
no_id_result = cmp.get_tariff_id({"name": "No Id Tariff"})
732+
has_id_result = cmp.get_tariff_id({"id": "has_id", "name": "Has Id Tariff"})
733+
734+
if no_id_result is not None:
735+
print("ERROR T27: get_tariff_id() should return None for an entry with no id, got {}".format(no_id_result))
736+
failed += 1
737+
elif not any("No Id Tariff" in msg for msg in logged):
738+
print("ERROR T27: get_tariff_id() should log a warning naming the id-less entry, got {}".format(logged))
739+
failed += 1
740+
elif has_id_result != "has_id":
741+
print("ERROR T27: get_tariff_id() should return the real id for an entry that has one, got {}".format(has_id_result))
742+
failed += 1
743+
else:
744+
print("PASS T27: get_tariff_id() returns None and warns for an id-less entry, the real id otherwise")
745+
746+
# ------------------------------------------------------------------
747+
# T28: run_all() is actually wired to call get_tariff_id() inside its tariff
748+
# loop, rather than T27 only proving the helper works in isolation -
749+
# otherwise run_all() could still index tariff["id"] directly (#5472)
750+
# ------------------------------------------------------------------
751+
run_all_source = inspect.getsource(Compare.run_all)
752+
loop_idx = run_all_source.find("for tariff in compare_list:")
753+
guard_idx = run_all_source.find("self.get_tariff_id(tariff)", loop_idx + 1) if loop_idx >= 0 else -1
754+
continue_idx = run_all_source.find("continue", guard_idx + 1) if guard_idx >= 0 else -1
755+
first_direct_index_idx = run_all_source.find('tariff["id"]', loop_idx + 1) if loop_idx >= 0 else -1
756+
757+
if loop_idx < 0 or guard_idx < 0 or continue_idx < 0:
758+
print("ERROR T28: could not locate a call to get_tariff_id() inside run_all()'s tariff loop - test may need updating")
759+
failed += 1
760+
elif not (loop_idx < guard_idx < continue_idx):
761+
print("ERROR T28: run_all() does not skip an id-less tariff before using its id")
762+
failed += 1
763+
elif first_direct_index_idx != -1 and first_direct_index_idx < continue_idx:
764+
print("ERROR T28: run_all() still indexes tariff[\"id\"] directly before the id guard takes effect")
765+
failed += 1
766+
else:
767+
print("PASS T28: run_all() calls get_tariff_id() inside its tariff loop before using the id")
768+
720769
if failed:
721770
print("**** compare tests FAILED: {} errors ****\n".format(failed))
722771
else:

0 commit comments

Comments
 (0)