Skip to content

Commit b8325a4

Browse files
CIclaude
andcommitted
Merge branch 'main' into fix/compare-restore-plan-preclip
Conflicts with #5488 (compare_list validation), resolved keeping both sides: - compare.py: main's check_compare_list()/valid_compare_list() and this branch's COMPARE_*_STATE sets both kept at module level; in run_all() the per-tariff log uses main's tariff_id and this branch's plan_valid = False follows it. - tests/test_compare.py: one import line carrying both sets of names. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents 9fee9cd + 5001c8e commit b8325a4

21 files changed

Lines changed: 2929 additions & 129 deletions

‎.claude/skills/issue-pr/SKILL.md‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,9 @@ This skill assumes the issue has already been triaged — the daemon only invoke
1414

1515
## 1. Read the ticket
1616

17-
Fetch it with `gh issue view <number> --json title,body,labels,comments`. The bot's own triage comment (opens with "automated first-pass triage") already has the classification, priority, and any root-cause pointer — start from there rather than re-investigating from scratch.
17+
Fetch it with `gh issue view <number> --json title,body,labels,comments`. The bot's own triage comment (opens with "automated first-pass triage") already has the classification, priority, and any root-cause pointer — start from there rather than re-investigating from scratch. If it's labelled `bug` and the triage reached a full investigation, the comment also has a one-line statement of the fault right after the classification — use that as the precise claim to fix, not the reporter's (often vaguer) symptom description. If the issue carries any follow-up bot comments (opening with "automated follow-up triage review"), check the most recent one too — it may have restated, sharpened, or only just written that statement, and its version of the claim supersedes the first-pass one. A statement that a later bot comment retracted no longer counts.
18+
19+
A `bug` label can arrive without that statement: the split and evidence gates (`/issue-triage` steps 1a and 1b) apply a type label and stop before any investigation. If no bot comment has a bug statement or a root-cause pointer, the fault hasn't been verified yet — don't fall back to the reporter's title or symptoms. Go to step 8 and say the issue needs a full triage (the follow-up review that the reporter's reply triggers) before a fix can be attempted.
1820

1921
## 2. Investigate
2022

‎.claude/skills/issue-triage-followup/SKILL.md‎

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,11 +50,18 @@ Compare your updated understanding against the original triage comment's classif
5050
- If the classification, priority, or root-cause should change given the new information, update it. You may change or remove a label **you previously applied as the bot** (classification, priority, `waiting_for_user`) — never remove a label a human added.
5151
- Apply the same duplicate check as `/issue-triage` step 4 if the new information suggests one.
5252

53+
If the bot's most recent prior comment (the one you found in step 1) carries a one-line bug statement (`/issue-triage` step 3), that statement is a standing claim and this review must settle it rather than silently carry it forward or drop it — including when nothing material changed, one of:
54+
55+
- **Still a bug** — restate the one-line statement in this comment, unchanged if the new evidence doesn't bear on it, updated if it sharpens or changes what the fault actually is.
56+
- **Not a bug after all** — say so plainly, retract the statement, and give the corrected classification. This is the expected outcome when, as happened on #5390, a maintainer's own reply shows the reported behaviour is Predbat doing what the config says.
57+
58+
If this review concludes the issue is a bug and there is no live statement to restate — the bot's most recent prior comment has none, or retracted one — write the one-line statement now, to the same standard as `/issue-triage` step 3. That covers this review changing the classification **to** `bug` (for the first time, or back to `bug` after an earlier follow-up retracted the statement), and this review confirming a `bug` label that an early-exit gate (`/issue-triage` step 1a or 1b) applied without investigating — those gates stop before step 3, so the first full investigation after one is where the statement gets written, even though the label is already there. A statement in an older comment that a later one retracted is not live, so it never stands in for a new one.
59+
5360
## 5. Post one follow-up comment
5461

5562
Check the comment history first: if the most recent comment is already a bot comment with nothing from a human posted after it, stop — this review already happened and nothing new has arrived since.
5663

57-
Post exactly one comment via `gh issue comment <number> --body "..."`, opening with a line disclosing this is an automated follow-up triage review (a maintainer will review before any action is taken), followed by: what was new since the last review, whether the classification/priority/root-cause changed and why (or confirmation nothing changed), and any further information request.
64+
Post exactly one comment via `gh issue comment <number> --body "..."`, opening with a line disclosing this is an automated follow-up triage review (a maintainer will review before any action is taken), followed by: what was new since the last review, whether the classification/priority/root-cause changed and why (or confirmation nothing changed), the one-line bug statement from step 4 whenever one is restated, newly written, or retracted there — restated as-is if the new evidence didn't bear on it — and any further information request.
5865

5966
Do **not** touch the `BOT_TRIAGED` or `BOT_REVIEW` labels — the daemon manages both.
6067

‎.claude/skills/issue-triage/SKILL.md‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,11 +73,13 @@ A `predbat_debug.yaml` can also be replayed against current main to reproduce th
7373

7474
Apply exactly one of: `bug`, `question`, `configuration` (user error/misconfiguration), `enhancement` (feature request). If genuinely ambiguous, apply `unclear` instead of guessing.
7575

76+
If you classify as `bug`, write a one-line statement of the bug in your own words — what Predbat actually does wrong, not the reporter's symptom description. State the faulty behaviour and, if you have one, the mechanism, e.g. "The dispatch-cancel check re-reads the car's live status instead of the status at cancel time, so a car that resumes charging a second later still loses its slot." A restatement of the title ("battery charging not working") is not acceptable. This line carries into step 9.
77+
7678
## 4. Check for duplicates
7779

7880
Search existing issues (`gh issue list --search ...`, both open and closed) for the same symptom.
7981

80-
- High confidence match: label `duplicate`, comment linking the original issue, and close this one.
82+
- High confidence match: label `duplicate`, comment linking the original issue (including the step 3 one-line bug statement, if you wrote one), and close this one.
8183
- Low confidence: mention "possibly related to #N" in your triage comment; don't close.
8284

8385
Then look for **similar** issues and PRs as well, whether or not they are duplicates: search both open and closed ones for the symptom, the integration and the component it touches (`gh issue list --state all --search ...` and `gh pr list --state all --search ...`). Read the findings in the best matches — what was diagnosed, what was fixed, and what was ruled out — before forming a hypothesis, and link the relevant ones in your triage comment as "related to #N". An issue that shares a symptom or a trigger is not necessarily the same problem: only call it a duplicate when the cause matches, not just the symptom, and treat a past finding as a lead to confirm against current main, not as a conclusion.
@@ -106,6 +108,7 @@ Then look for **similar** issues and PRs as well, whether or not they are duplic
106108

107109
- Form a root-cause hypothesis with a `file:line` pointer if the investigation supports one. It's fine to say the cause needs maintainer review if it doesn't.
108110
- You may edit files locally to test a hypothesis (e.g. a temporary debug print, a tweaked test fixture) — this clone is hard-reset and cleaned before the next run, so nothing here persists. Never `git commit` or `git push`.
111+
- If this investigation changes your mind about step 3's classification — e.g. you classified `bug` but the investigation shows Predbat is doing what the config says — go back and revise it, and drop or rewrite the one-line bug statement to match. Don't let step 9 post a statement step 3 wrote before you knew better.
109112

110113
## 6. Apply component labels
111114

@@ -131,6 +134,8 @@ Check existing comments first — if one from you is already there, stop; don't
131134

132135
Post exactly one comment via `gh issue comment <number> --body "..."`, opening with a line disclosing this is an automated first-pass triage (a maintainer will review before any action is taken), followed by: classification, priority (if set), what you investigated and found (including test result if you ran one), a root-cause pointer if you have one, and any information request.
133136

137+
If you reached step 3 and classified as `bug`, lead with the one-line bug statement immediately after the classification. Gates 1a/1b apply a type label without ever reaching step 3, so there's nothing to include if you took one of those paths.
138+
134139
After posting, apply the `BOT_TRIAGED` label via `gh issue edit <number> --add-label BOT_TRIAGED` — every triage run gets this label, including a duplicate-close, regardless of classification. It marks the issue as triaged for the separate PR-creation flow (see `.claude/skills/issue-pr/SKILL.md`).
135140

136141
## Guardrails

‎.cspell/custom-dictionary-workspace.txt‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -145,6 +145,7 @@ denorm
145145
derate
146146
derated
147147
derating
148+
dess
148149
desync
149150
desynced
150151
devcontainer
@@ -170,6 +171,7 @@ dtype
170171
dwindow
171172
eauth
172173
echargingpile
174+
ECONNRESET
173175
ecosmart
174176
Eddi
175177
eddis
@@ -195,6 +197,7 @@ energythroughput
195197
enho
196198
Enlighten
197199
enlm
200+
ENOTFOUND
198201
enph
199202
enphase
200203
enphaseenergy
@@ -204,6 +207,7 @@ epod
204207
epvtoday
205208
errno
206209
ESSAPI
210+
ETIMEDOUT
207211
etoday
208212
etok
209213
euids
@@ -348,6 +352,7 @@ labelcolor
348352
labelledby
349353
labelnames
350354
ladybugdb
355+
Lanmate
351356
larr
352357
libbi
353358
libc
@@ -472,6 +477,7 @@ overfitting
472477
oversizing
473478
overvoltage
474479
ownerapi
480+
pac
475481
parseable
476482
pbat
477483
pbgw
@@ -531,6 +537,7 @@ pyproject
531537
pytest
532538
pythonhashseed
533539
pytz
540+
Quattro
534541
Qwen
535542
randn
536543
rany2

‎apps/predbat/compare.py‎

Lines changed: 71 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,65 @@
2727
# 3. Consider Octopus API key to access current tariff info and switch links
2828

2929

30+
# An empty 'config:' line means no overrides; any other non-mapping value can't be applied
31+
EMPTY_COMPARE_CONFIG = (None, "", [], {})
32+
33+
34+
def check_compare_list(compare_list):
35+
"""Split compare_list into the entries that can be compared and a message per skipped entry (#5472).
36+
37+
Each kept entry is a copy with its id and name as stripped strings, and its config either a
38+
mapping of overrides or absent, so run_all() and fetch_config() can rely on that shape.
39+
"""
40+
if not compare_list:
41+
return [], []
42+
if not isinstance(compare_list, list):
43+
return [], ["compare_list must be a list of tariffs, each with an id and a name"]
44+
45+
valid = []
46+
problems = []
47+
seen_ids = set()
48+
for index, entry in enumerate(compare_list):
49+
problem = None
50+
entry_id = ""
51+
if not isinstance(entry, dict):
52+
problem = "must be a dictionary with an id and a name"
53+
else:
54+
# Results, sensor names and the web page are all keyed by the id as a string, so an
55+
# unquoted numeric id in apps.yaml must not reach them as an int
56+
entry_id = "" if entry.get("id") is None else str(entry["id"]).strip()
57+
# A numeric name would likewise break publish_data() building the friendly name
58+
entry_name = "" if entry.get("name") is None else str(entry["name"]).strip()
59+
if not entry_id:
60+
problem = "requires a non-empty 'id'"
61+
elif entry_id in seen_ids:
62+
problem = "has a duplicate id '{}'".format(entry_id)
63+
elif not entry_name:
64+
problem = "('{}') requires a non-empty 'name'".format(entry_id)
65+
elif entry.get("config") not in EMPTY_COMPARE_CONFIG and not isinstance(entry["config"], dict):
66+
# run_all() and fetch_config() look overrides up by key; a list of them raised TypeError
67+
# there and ended the whole comparison, and any other value was never a usable override
68+
problem = "('{}') has a 'config' that isn't a set of 'setting: value' overrides".format(entry_id)
69+
if problem:
70+
problems.append("compare_list entry {} {}".format(index + 1, problem))
71+
continue
72+
seen_ids.add(entry_id)
73+
checked = dict(entry, id=entry_id, name=entry_name)
74+
if checked.get("config") in EMPTY_COMPARE_CONFIG:
75+
checked.pop("config", None)
76+
valid.append(checked)
77+
return valid, problems
78+
79+
80+
def valid_compare_list(compare_list, log=None):
81+
"""Return the compare_list entries that can be compared, logging a warning for each one skipped (#5472)."""
82+
valid, problems = check_compare_list(compare_list)
83+
if log:
84+
for problem in problems:
85+
log("Warn: Compare, {}, skipping it".format(problem))
86+
return valid
87+
88+
3089
# What Compare.run_all() saves before comparing tariffs and restores afterwards: every tariff fetches, plans and
3190
# predicts on the live instance. The plan state is what calculate_plan() writes that outlives the run - leaving
3291
# plan_preclip as the last tariff's plan once made the first re-plan after midnight score the wrong plan as the
@@ -537,12 +596,14 @@ def load_yaml(self):
537596
try:
538597
data = yaml.safe_load(f)
539598
if data:
540-
self.comparisons = data.get("comparisons", {})
599+
# str() keys: a numeric id saved before ids were normalised (#5472) would
600+
# otherwise break publish_data() and never match its compare_list entry again
601+
self.comparisons = {str(tariff_id): result for tariff_id, result in (data.get("comparisons") or {}).items()}
541602
except yaml.YAMLError as exc:
542603
self.log("Error loading comparisons: {}".format(exc))
543604

544605
if self.comparisons:
545-
compare_list = self.pb.get_arg("compare_list", [])
606+
compare_list = valid_compare_list(self.pb.get_arg("compare_list", []))
546607
self.select_best(compare_list, self.comparisons)
547608
self.publish_data()
548609

@@ -566,7 +627,8 @@ def publish_data(self):
566627

567628
if result:
568629
cost = result.get("cost", 0)
569-
name = result.get("name", "")
630+
# str(): a numeric name saved before names were normalised (#5472) must not crash startup
631+
name = str(result.get("name", ""))
570632
attributes = {
571633
"friendly_name": "Compare " + name,
572634
"state_class": "measurement",
@@ -593,7 +655,7 @@ def publish_only(self):
593655
"""
594656
Update HA sensors only
595657
"""
596-
compare_list = self.pb.get_arg("compare_list", [])
658+
compare_list = valid_compare_list(self.pb.get_arg("compare_list", []))
597659
if not compare_list:
598660
return
599661

@@ -651,7 +713,7 @@ def run_all(self, debug=False, fetch_sensor=True):
651713
Compare a comparison in prices across multiple energy tariffs and report results
652714
take care not to destroy the state of the system for the primary settings
653715
"""
654-
compare_list = self.pb.get_arg("compare_list", [])
716+
compare_list = valid_compare_list(self.pb.get_arg("compare_list", []), log=self.log)
655717
if not compare_list:
656718
return
657719

@@ -695,7 +757,8 @@ def run_all(self, debug=False, fetch_sensor=True):
695757

696758
try:
697759
for tariff in compare_list:
698-
prior = results.get(tariff["id"], {})
760+
tariff_id = tariff["id"]
761+
prior = results.get(tariff_id, {})
699762
if prior:
700763
prior_date = prior.get("date", "")[:10]
701764
if prior_date == today_date:
@@ -707,14 +770,14 @@ def run_all(self, debug=False, fetch_sensor=True):
707770
else:
708771
# First ever run for this tariff: start from actual midnight SoC
709772
start_soc = soc_midnight_fallback
710-
self.log("Compare tariff {} starting SoC: {:.2f} kWh".format(tariff.get("id", ""), start_soc))
773+
self.log("Compare tariff {} starting SoC: {:.2f} kWh".format(tariff_id, start_soc))
711774
# Each tariff plans from scratch: scored against the previous tariff's plan, the keep-or-switch threshold
712775
# in calculate_plan() could leave one tariff's result as another tariff's plan
713776
my_predbat.plan_valid = False
714777
try:
715778
result_data = self.run_single(tariff, rate_import_base, rate_export_base, end_record, debug=debug, fetch_sensor=fetch_sensor, car_charging_slots=saved["car_charging_slots"], start_soc=start_soc, io_adjusted_base=io_adjusted_base)
716779
if result_data is not None:
717-
results[tariff["id"]] = result_data
780+
results[tariff_id] = result_data
718781
finally:
719782
# Restore hardware settings after each tariff so overrides don't bleed into the next tariff;
720783
# in a finally so a raised run_single() can't leave compare values applied to the live instance

0 commit comments

Comments
 (0)