Skip to content

Commit e814aa5

Browse files
fix(output): narrow #4445 to the Chrg/HoldChrg decision, keep displaying the predicted SoC
Decide Chrg vs HoldChrg from the real charge_limit_best, but keep the Limit cell, the reason text and state_target on the window's predicted end-of-window SoC, which is what docs/predbat-plan-card.md says the Limit column shows and what #4445's agreed fix direction asked for. Showing the instruction instead made every window that clip_charge_slots raises to a full charge read 100% while the plan only expects it to reach a lower SoC. This restores get_charge_export_text and the plan_debug bracket order (predicted, then limit) to their main behaviour, and reworks the tests: the SoC-between-target-and-limit case fails on main with HoldChrg, a real hold still shows HoldChrg, and the plan_debug cell and text summary show the predicted SoC. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent f79bbf0 commit e814aa5

2 files changed

Lines changed: 42 additions & 52 deletions

File tree

‎apps/predbat/output.py‎

Lines changed: 12 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -852,20 +852,11 @@ def get_charge_export_text(self, minutes_now, charge_window_n, export_window_n):
852852
else:
853853
text = "force exporting to {}% for the next {}".format(target_export, self.duration_string(self.export_window_best[export_window_n]["end"] - minutes_now))
854854
elif charge_window_n >= 0:
855-
limit = self.charge_limit_best[charge_window_n]
856-
target_charge = calc_percent_limit(limit, self.soc_max)
857-
predicted = self.charge_window_best[charge_window_n].get("target", limit)
858-
predicted_percent = calc_percent_limit(predicted, self.soc_max)
859-
# Report the real instruction sent to the inverter (charge_limit_best), not the window's
860-
# predicted-achievable SoC (plan.py's "target"), which would show a full-rate charge as a
861-
# lower, partial one (issue #4445). Where the window can't reach the limit in time, the
862-
# achievable SoC is still shown alongside it, so that information (the point of PR #2358)
863-
# isn't confined to plan_debug.
864-
target_suffix = "" if predicted_percent == target_charge else " (predicted {}%)".format(predicted_percent)
865-
if limit == self.reserve:
866-
text = "freeze charging to {}%{} for the next {}".format(target_charge, target_suffix, self.duration_string(self.charge_window_best[charge_window_n]["end"] - minutes_now))
855+
target_charge = calc_percent_limit(self.charge_window_best[charge_window_n].get("target", self.charge_limit_best[charge_window_n]), self.soc_max)
856+
if self.charge_limit_best[charge_window_n] == self.reserve:
857+
text = "freeze charging to {}% for the next {}".format(target_charge, self.duration_string(self.charge_window_best[charge_window_n]["end"] - minutes_now))
867858
else:
868-
text = "charging to {}%{} for the next {}".format(target_charge, target_suffix, self.duration_string(self.charge_window_best[charge_window_n]["end"] - minutes_now))
859+
text = "charging to {}% for the next {}".format(target_charge, self.duration_string(self.charge_window_best[charge_window_n]["end"] - minutes_now))
869860
else:
870861
charge_window_n = self.get_next_charge_window(minutes_now)
871862
export_window_n = self.get_next_export_window(minutes_now)
@@ -1432,15 +1423,20 @@ def import_rate_color(rate):
14321423
if "target" in self.charge_window_best[charge_window_n]:
14331424
target = self.charge_window_best[charge_window_n]["target"]
14341425

1435-
limit_percent = calc_percent_limit(limit, self.soc_max)
1426+
# Classify Chrg vs HoldChrg against the real instruction (charge_limit_best), but display the
1427+
# window's predicted end-of-window SoC ("target", from plan.py), which is what the Limit column
1428+
# documents. Classifying against the prediction showed a full-rate charge that can't finish
1429+
# within the window as HoldChrg whenever the row's SoC sat above that prediction (issue #4445).
1430+
instruction_percent = calc_percent_limit(limit, self.soc_max)
1431+
limit_percent = calc_percent_limit(target, self.soc_max)
14361432
if limit > 0.0:
14371433
if self.is_freeze_charge(limit):
14381434
state = "FrzChrg&rarr;"
14391435
state_color = "#EEEEEE"
14401436
raw_state = "FrzChrg"
14411437
limit_percent = soc_percent
14421438
reason_parts.append({"code": "freeze_charge", "params": {"rate": rate_text_import, "threshold": "{:.2f}".format(import_cost_threshold)}})
1443-
elif limit_percent <= soc_percent_min_window:
1439+
elif instruction_percent <= soc_percent_min_window:
14441440
state = "HoldChrg&rarr;"
14451441
state_color = "#34DBEB"
14461442
raw_state = "HoldChrg"
@@ -1463,7 +1459,7 @@ def import_rate_color(rate):
14631459
show_limit = str(limit_percent)
14641460
had_state = True
14651461
if plan_debug:
1466-
show_limit += " ({})".format(str(calc_percent_limit(target, self.soc_max)))
1462+
show_limit += " ({})".format(str(calc_percent_limit(limit, self.soc_max)))
14671463
raw_state_target = str(limit_percent)
14681464
else:
14691465
if export_window_n >= 0:

‎apps/predbat/tests/test_plan_why_reason.py‎

Lines changed: 30 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -416,65 +416,59 @@ def render(car_hold_minutes=None):
416416
print("ERROR: HoldChrg rendered text unexpected: {}".format(_render(row, templates)))
417417
failed = True
418418

419-
# --- Test 2b: a genuine full-rate charge must not misclassify as HoldChrg just because the
420-
# window's "target" (plan.py's predicted end-of-window SoC) sits below the real charge_limit_best -
421-
# this is the #4445 regression: a charge that can't complete within one window previously showed
422-
# HoldChrg -> target% instead of Chrg -> limit% ---
423-
print("Test Chrg reason is not hidden by a target below the real limit (#4445)")
419+
# --- Test 2b: #4445 - a full-rate charge that can't finish within the window carries a "target"
420+
# (plan.py's predicted end-of-window SoC) below the real charge_limit_best. Chrg vs HoldChrg must be
421+
# decided from the real limit, while the Limit cell, reason and state_target keep showing the
422+
# predicted SoC, as docs/predbat-plan-card.md describes the Limit column ---
423+
print("Test Chrg vs HoldChrg is decided by the real limit, with the predicted SoC displayed (#4445)")
424424
my_predbat.charge_window_best[0]["target"] = 6.0 # predicted to only reach 60% (6kWh of 10kWh soc_max) by window end
425-
my_predbat.predict_soc_best = _flat_soc(my_predbat, 2.0) # 20%, well below the 80% limit
426-
_, raw_plan = render()
427-
row = _get_row(raw_plan, minutes_now)
428-
if row is None or _codes(row) != ["charge_low_rate"]:
429-
print("ERROR: #4445 Chrg reasons unexpected: {}".format(row and _codes(row)))
430-
failed = True
431-
elif row["reasons"][0]["params"].get("target_percent") != 80:
432-
print("ERROR: #4445 Chrg should classify against the real limit (80), not target: {}".format(row["reasons"][0]["params"]))
433-
failed = True
434-
elif "Charging up to 80" not in _render(row, templates):
435-
print("ERROR: #4445 Chrg rendered text unexpected: {}".format(_render(row, templates)))
436-
failed = True
437425

438-
# The reported #4445 symptom itself: with the SoC already above the predicted target but still
439-
# below the real limit, classifying against the target showed HoldChrg -> 60% for a window that is
440-
# really charging towards 80%
426+
# The reported symptom: the SoC is already above the predicted 60% but below the real 80% limit, so
427+
# classifying against the prediction showed HoldChrg for a window that is really charging
441428
my_predbat.predict_soc_best = _flat_soc(my_predbat, 7.0) # 70%, between the 60% target and the 80% limit
442429
_, raw_plan = render()
443430
row = _get_row(raw_plan, minutes_now)
444431
if row is None or _codes(row) != ["charge_low_rate"]:
445432
print("ERROR: #4445 SoC between target and limit should show Chrg, not HoldChrg: {}".format(row and _codes(row)))
446433
failed = True
447-
elif row["reasons"][0]["params"].get("target_percent") != 80:
448-
print("ERROR: #4445 SoC between target and limit should report the real limit (80): {}".format(row["reasons"][0]["params"]))
434+
elif row["reasons"][0]["params"].get("target_percent") != 60:
435+
print("ERROR: #4445 Chrg reason should show the predicted SoC (60): {}".format(row["reasons"][0]["params"]))
449436
failed = True
450-
elif row["state"] != "Chrg" or row["state_target"] != "80":
437+
elif row["state"] != "Chrg" or row["state_target"] != "60":
451438
print("ERROR: #4445 SoC between target and limit state unexpected: {} -> {}".format(row["state"], row["state_target"]))
452439
failed = True
440+
elif "Charging up to 60" not in _render(row, templates):
441+
print("ERROR: #4445 Chrg rendered text unexpected: {}".format(_render(row, templates)))
442+
failed = True
453443

454-
# With plan_debug on, the Limit cell shows the real limit with the predicted end-of-window SoC in brackets
444+
# With plan_debug on, the Limit cell shows the predicted SoC with the real limit in brackets
455445
plan_debug_saved = my_predbat.plan_debug
456446
my_predbat.plan_debug = True
457447
html, _ = render()
458448
my_predbat.plan_debug = plan_debug_saved
459-
if "80 (60)" not in html:
460-
print("ERROR: #4445 plan_debug Limit cell should read '80 (60)' (limit, then predicted SoC)")
449+
if "60 (80)" not in html:
450+
print("ERROR: #4445 plan_debug Limit cell should read '60 (80)' (predicted SoC, then limit)")
461451
failed = True
462-
del my_predbat.charge_window_best[0]["target"]
463452

464-
# --- Test 2c: get_charge_export_text has the same #4445 fix, and keeps surfacing the
465-
# predicted-achievable SoC (PR #2358's original point) alongside the real limit when they diverge ---
466-
print("Test get_charge_export_text shows the real limit, with the predicted SoC noted when it diverges")
467-
text = my_predbat.get_charge_export_text(minutes_now, 0, -1)
468-
if "charging to 80%" not in text or "predicted" in text:
469-
print("ERROR: get_charge_export_text with no target divergence unexpected: {}".format(text))
453+
# A real hold still classifies as HoldChrg: the SoC is at or above the real limit
454+
my_predbat.predict_soc_best = _flat_soc(my_predbat, 9.0) # 90%, above the 80% limit
455+
_, raw_plan = render()
456+
row = _get_row(raw_plan, minutes_now)
457+
if row is None or _codes(row) != ["hold_charge_at_target"]:
458+
print("ERROR: #4445 SoC above the real limit should still show HoldChrg: {}".format(row and _codes(row)))
470459
failed = True
471460

472-
my_predbat.charge_window_best[0]["target"] = 6.0 # predicted to only reach 60% (6kWh of 10kWh soc_max) by window end
461+
# --- Test 2c: get_charge_export_text reports the predicted SoC too, falling back to the limit ---
462+
print("Test get_charge_export_text shows the predicted SoC, or the limit when there is no prediction")
473463
text = my_predbat.get_charge_export_text(minutes_now, 0, -1)
474-
if "charging to 80%" not in text or "predicted 60%" not in text:
475-
print("ERROR: get_charge_export_text with a diverging target unexpected: {}".format(text))
464+
if "charging to 60%" not in text:
465+
print("ERROR: get_charge_export_text with a predicted target unexpected: {}".format(text))
476466
failed = True
477467
del my_predbat.charge_window_best[0]["target"]
468+
text = my_predbat.get_charge_export_text(minutes_now, 0, -1)
469+
if "charging to 80%" not in text:
470+
print("ERROR: get_charge_export_text with no predicted target unexpected: {}".format(text))
471+
failed = True
478472

479473
# --- Test 3: FrzChrg ---
480474
print("Test FrzChrg reason")

0 commit comments

Comments
 (0)