Skip to content

Commit 79499d6

Browse files
fix(output): hold_for_car follows the plan's car model, split wording, test isolation
- car_charging_hold_active() reads the car slots, limit and SoC of the model the row was drawn from: the given prediction, else what the live prediction gets - car_charging_slots_model() and car_charging_limit_model. So a car reporting car_charging_now outside its plan shows the hold execute_plan() applies since #5245. The docstring and output.py comment no longer claim prediction.py and execute.py share one car-full gate (#4967 uncaps it). - Yesterday's actual-history table shows measured SoC, so its car icon now follows the recorded "Hold for car" status (publish_html_plan's new car_hold_minutes), not the model. Both rows and split segments go through one plan_row_holding_for_car(). - A split row held for the car before an export uses a new hold_for_car_before_export reason ("Until HH:MM, ...") and the JS joins it to the export half with "Then", like the demand split. - Tests: the car hold tests save/restore car state and set SoC/limit explicitly; new cases for a car slot starting with the export, from-battery on in a split, a #5229 cancelled (kwh 0) slot, set_charge_window off, car_charging_now with no slot, the prediction's own limit, recorded hold minutes, and the Yesterday minutes-ago mapping; Test 12 checks the JS "Then" branch; the _render mirror uses >= 2 like the JS. - Docs: the plan card describes the car icon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
1 parent ebb5edc commit 79499d6

6 files changed

Lines changed: 332 additions & 141 deletions

File tree

‎apps/predbat/output.py‎

Lines changed: 31 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,8 @@
3434
"demand_falling": "Demand — the battery is expected to discharge to cover house load; no charging or exporting is scheduled this slot.",
3535
"demand_steady": "Demand — battery level is expected to stay steady; no charging or exporting is scheduled this slot.",
3636
"hold_for_car": "Hold for car — the battery is prevented from discharging while the car charges; house load beyond what solar covers comes from the grid instead.",
37+
# First half of a split slot held for a car - worded like the demand_before_export_* codes below
38+
"hold_for_car_before_export": "Until {split_time}, the battery is prevented from discharging while the car charges.",
3739
# Used for the first half of a split slot where the export window only starts partway through -
3840
# deliberately worded without the "nothing is scheduled this slot" clause of the plain demand
3941
# reasons above, which would contradict the export reason sitting alongside it in the same slot.
@@ -1103,9 +1105,13 @@ def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_star
11031105
)
11041106
return dp2(charge_rate_now_curve * MINUTE_WATT / 1000.0)
11051107

1106-
def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, load_minutes_step, load_minutes_step10, end_record, publish=True, prediction=None):
1108+
def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, load_minutes_step, load_minutes_step10, end_record, publish=True, prediction=None, car_hold_minutes=None):
11071109
"""
11081110
Publish the current plan in HTML format
1111+
1112+
car_hold_minutes, when given, is the set of plan minutes whose recorded status was "Hold for car":
1113+
the Yesterday actual-history table shows measured SoC, so its car icon comes from what happened
1114+
rather than from the model (see plan_row_holding_for_car()).
11091115
"""
11101116
html = ""
11111117
plan_debug = self.plan_debug
@@ -1321,14 +1327,9 @@ def import_rate_color(rate):
13211327
else:
13221328
soc_sym = "&searr;"
13231329

1324-
# Mirrors the discharge hold execute.py applies for a live "Hold for car" status and
1325-
# prediction.py enforces during the plan itself (car charging slot active, car not
1326-
# allowed to draw from the battery, charge windows in use) - so the plan explains the
1327-
# same held SoC that a non-read-only user would see labelled "Hold for car" live.
1328-
# car_charging_hold_active() also gates on car capacity the same way execute.py and
1329-
# prediction.py do, so a car that's already full does not show the hold reason for a
1330-
# slot it no longer actually holds the battery for (Copilot review on #5147).
1331-
holding_for_car = self.set_charge_window and (not self.car_charging_from_battery) and self.car_charging_hold_active(minute_start, minute_end)
1330+
# The discharge hold for a charging car, so the row explains a held SoC with the same "Hold for
1331+
# car" execute.py shows live (#5147 review)
1332+
holding_for_car = self.plan_row_holding_for_car(minute_start, minute_end, prediction, car_hold_minutes)
13321333

13331334
state = "&#128663;" if holding_for_car else soc_sym
13341335
state_color = "#FFFFFF"
@@ -1462,13 +1463,13 @@ def import_rate_color(rate):
14621463
# #5147). Same "car icon replaces the trend arrow/reason" convention as the
14631464
# whole-row case above, so this takes priority over the trend below rather
14641465
# than being shown alongside it.
1465-
holding_for_car_segment = self.set_charge_window and (not self.car_charging_from_battery) and self.car_charging_hold_active(minute_start, start)
1466+
holding_for_car_segment = self.plan_row_holding_for_car(minute_start, start, prediction, car_hold_minutes)
14661467

14671468
soc_change_this = self.predict_soc_best.get(max(start - self.minutes_now, 0), 0.0) - self.predict_soc_best.get(minute_relative_start, 0.0)
14681469
split_time_str = (self.midnight_utc + timedelta(minutes=start)).strftime("%H:%M")
14691470
if holding_for_car_segment:
14701471
state = "&#128663;"
1471-
reason_parts.append({"code": "hold_for_car", "params": {}})
1472+
reason_parts.append({"code": "hold_for_car_before_export", "params": {"split_time": split_time_str}})
14721473
# Same near-flat tolerance as the whole-slot demand arrow above - testing
14731474
# soc_change_this >= 0 first would make the steady case unreachable and
14741475
# render a flat pre-window period as rising
@@ -3165,6 +3166,18 @@ def yesterday_reconstruct_car_slots(self, end_record, yesterday_load_step, minut
31653166
load_value = yesterday_load_step.get(minute, 0)
31663167
yesterday_load_step[minute] = max(load_value - subtract_amount, 0)
31673168

3169+
def plan_row_holding_for_car(self, minute_start, minute_end, prediction=None, car_hold_minutes=None):
3170+
"""
3171+
Whether a plan row (or a split row's pre-export segment) shows the battery held for a charging car.
3172+
3173+
With car_hold_minutes (the Yesterday actual-history table, whose SoC is measured) it is whether any
3174+
minute's recorded status was "Hold for car". Otherwise it is the hold the model assumes: charge
3175+
windows in use, the car not allowed to draw from the battery, and car_charging_hold_active().
3176+
"""
3177+
if car_hold_minutes is not None:
3178+
return any(minute in car_hold_minutes for minute in range(minute_start, minute_end))
3179+
return bool(self.set_charge_window and (not self.car_charging_from_battery) and self.car_charging_hold_active(minute_start, minute_end, prediction))
3180+
31683181
def calculate_yesterday(self):
31693182
"""
31703183
Calculate the base plan for yesterday
@@ -3430,13 +3443,17 @@ def calculate_yesterday(self):
34303443
self.predict_metric_best = cost_yesterday_array
34313444

34323445
# Fake charge/export windows based on previous predbat status
3446+
car_hold_minutes = set()
34333447
if predbat_status_data:
34343448
predbat_status = minute_data_state(predbat_status_data[0], 2, self.now_utc, "state", "last_updated")
34353449
for minute in predbat_status:
34363450
status = predbat_status[minute]
34373451
if "," in status:
34383452
# If there are multiple statuses take the first one
34393453
predbat_status[minute] = status.split(",")[0].strip()
3454+
# The car icon on this table follows the recorded "Hold for car" status, as its SoC is measured.
3455+
# predbat_status is keyed by minutes ago; plan-minute m is (minutes_now + end_record - m) ago.
3456+
car_hold_minutes = {minutes_now + end_record - minutes_ago for minutes_ago, status in predbat_status.items() if status.lower() == "hold for car"}
34403457
# Ignore the first and last edge_minutes of each slot when they don't hold one state
34413458
# throughout - Predbat's reported status can lag a slot boundary by a minute or two while
34423459
# it catches up to a replan, and that leftover from the previous (or next) slot must not
@@ -3542,7 +3559,9 @@ def calculate_yesterday(self):
35423559

35433560
# Simulate yesterday with actual charge/export windows
35443561
self.forecast_minutes = end_record + minutes_now
3545-
plan_html_yesterday, plan_json_yesterday = self.publish_html_plan(yesterday_pv_step, yesterday_pv_step, yesterday_load_step, yesterday_load_step, end_record + minutes_now, publish=False, prediction=self.prediction)
3562+
plan_html_yesterday, plan_json_yesterday = self.publish_html_plan(
3563+
yesterday_pv_step, yesterday_pv_step, yesterday_load_step, yesterday_load_step, end_record + minutes_now, publish=False, prediction=self.prediction, car_hold_minutes=car_hold_minutes
3564+
)
35463565
self.forecast_minutes = end_record
35473566

35483567
# Restore state

‎apps/predbat/plan.py‎

Lines changed: 30 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5781,34 +5781,41 @@ def plan_car_charging(self, car_n, low_rates):
57815781
plan = self.sort_window_by_time(plan)
57825782
return plan
57835783

5784-
def car_charging_hold_active(self, minute_start, minute_end):
5785-
"""
5786-
True if a car with a planned charging slot covering this window still wants the battery
5787-
held - i.e. matches execute.py's real "Hold for car" gate (lines 596-601), not just "is
5788-
there a positive-kwh planned slot here".
5789-
5790-
execute.py only holds while car_charging_soc[car_n] < car_charging_limit[car_n]: once a
5791-
car has reached its target, execute.py stops holding for it even though its planned slot
5792-
may still show positive kwh (that kwh is the amount originally planned, not adjusted once
5793-
the car finishes early). prediction.py's own simulation does the same thing internally -
5794-
car_load_scale is clamped to car_charging_limit[car_n] - car_soc[car_n], floored at 0 -
5795-
so a full car draws no simulated load and the battery is not actually held for it either.
5796-
Mirroring only the "positive planned kwh" half of that here would show the hold_for_car
5797-
icon/reason for a slot where neither execute.py nor the plan simulation is actually
5798-
holding (Copilot review on #5147).
5799-
5800-
Like execute.py, this checks car_charging_soc/car_charging_limit once per car (the current
5801-
live values at plan-render time), not a per-minute simulated SoC - car_charge_slot_kwh()'s
5802-
per-minute window overlap is enough to know a car is relevant to this window; whether that
5803-
car is already full is a single live fact, not something that changes minute to minute
5804-
within one render.
5784+
def car_charging_hold_active(self, minute_start, minute_end, prediction=None):
5785+
"""
5786+
True if the plan holds the battery for a car during this window: a car that is not yet full
5787+
has a slot with energy overlapping it.
5788+
5789+
Follows the model the plan row was drawn from, so the car icon matches the held SoC that model
5790+
predicts. With a prediction (e.g. Yesterday's baseline) that is its own car slots, limit and SoC.
5791+
Without one it is what the live plan's prediction is given: car_charging_slots_model(), which adds a car
5792+
reporting car_charging_now outside its plan (#5245), and car_charging_limit_model where set,
5793+
which uncaps a car charging now (#5245) and, with octopus_intelligent_consider_full off, a car
5794+
with Octopus Intelligent slots (#4967).
5795+
5796+
That matches execute.py's hold for a car charging now, which ignores the modelled car SoC. For
5797+
a slot-only hold execute.py still stops once car_charging_soc reaches car_charging_limit, so on
5798+
the #4967 uncapped path the plan can show a hold that execute.py skips for a car it believes
5799+
full - the plan's flat SoC assumes the hold, and the icon explains that SoC (#5147 review).
5800+
5801+
The full check uses the SoC once per car at render time, while prediction.py clamps per minute, so
5802+
a car the model fills part-way through the plan keeps its icon on later slots that still carry kWh.
5803+
Rare in practice: planned slots are sized to the car's limit.
58055804
"""
58065805
if self.num_cars == 0:
58075806
return False
5807+
if prediction is not None:
5808+
car_slots = prediction.car_charging_slots
5809+
car_limit = prediction.car_charging_limit
5810+
car_soc = prediction.car_charging_soc
5811+
else:
5812+
car_slots = self.car_charging_slots_model()
5813+
car_limit = self.car_charging_limit_model if self.car_charging_limit_model is not None else self.car_charging_limit
5814+
car_soc = self.car_charging_soc
58085815
for car_n in range(self.num_cars):
5809-
if self.car_charging_soc[car_n] >= self.car_charging_limit[car_n]:
5816+
if car_soc[car_n] >= car_limit[car_n]:
58105817
continue
5811-
for window in self.car_charging_slots[car_n]:
5818+
for window in car_slots[car_n]:
58125819
if window["start"] < minute_end and window["end"] > minute_start and window.get("kwh", 0) > 0:
58135820
return True
58145821
return False

‎apps/predbat/tests/test_calculate_yesterday.py‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1524,6 +1524,64 @@ def _capture_publish_html_plan(*args, **kwargs):
15241524
return failed
15251525

15261526

1527+
def _test_recorded_hold_for_car_minutes(my_predbat, failed):
1528+
"""The actual-history table's car icon comes from the recorded "Hold for car" status (#5147 review).
1529+
1530+
That table shows measured SoC, so the car icon must follow what happened rather than the model.
1531+
calculate_yesterday() passes publish_html_plan() the plan minutes whose status was "Hold for car",
1532+
mapped from minutes-ago the same way as the charge/export reconstruction: plan-minute m is
1533+
(1800 - m) minutes ago here. Holds for plan-minutes 1205-1209, deliberately off-centre in its slot.
1534+
"""
1535+
print("calculate_yesterday: Test - recorded Hold for car minutes are passed to the actual-history table (#5147)")
1536+
now_utc = _setup_base(my_predbat)
1537+
prefix = my_predbat.prefix
1538+
1539+
start = now_utc - timedelta(minutes=1800)
1540+
status_points = []
1541+
for step in range(0, 1800, 5):
1542+
stamp = start + timedelta(minutes=step)
1543+
status_points.append({"state": "Hold for car" if 1205 <= step < 1210 else "Demand", "last_updated": stamp.strftime("%Y-%m-%dT%H:%M:%S+00:00"), "attributes": {"p/kWh": "0.0"}})
1544+
status_hist = [status_points]
1545+
1546+
def _history_with_one_hold(entity_id, days=30, required=True, tracked=True):
1547+
"""Return the recorded history calculate_yesterday() asks for, with one short Hold for car."""
1548+
if entity_id == prefix + ".cost_today":
1549+
return _make_constant_history(100.0, now_utc)
1550+
elif entity_id == prefix + ".soc_kw_h0":
1551+
return _make_constant_history(5.0, now_utc)
1552+
elif entity_id == prefix + ".status":
1553+
return status_hist
1554+
return None
1555+
1556+
captured = {}
1557+
1558+
def _capture_publish_html_plan(*args, **kwargs):
1559+
"""Record the car_hold_minutes the actual-history table is rendered with."""
1560+
captured["car_hold_minutes"] = kwargs.get("car_hold_minutes")
1561+
return ("", "{}")
1562+
1563+
my_predbat.step_data_history = _make_mock_step_data(my_predbat.pv_today)
1564+
my_predbat.get_history_wrapper = _history_with_one_hold
1565+
my_predbat.plan_write_debug = lambda *a, **kw: ("", "{}")
1566+
my_predbat.publish_html_plan = _capture_publish_html_plan
1567+
original_run_pred = my_predbat.run_prediction
1568+
my_predbat.run_prediction = lambda *a, **kw: _make_mock_run_prediction([])(my_predbat, *a, **kw)
1569+
1570+
my_predbat.calculate_yesterday()
1571+
1572+
hold = captured.get("car_hold_minutes")
1573+
if hold is None:
1574+
print("ERROR: the actual-history table was rendered without car_hold_minutes")
1575+
failed = True
1576+
elif hold != set(range(1205, 1210)):
1577+
print("ERROR: recorded Hold for car at plan-minutes 1205-1209 mapped to {}".format(sorted(hold)[:10]))
1578+
failed = True
1579+
1580+
_restore_methods(my_predbat, original_run_pred)
1581+
my_predbat.savings_last_updated = None
1582+
return failed
1583+
1584+
15271585
def _test_more_active_slot_status(my_predbat, failed):
15281586
"""Unit tests for the slot-status collapse rule (#4843).
15291587
@@ -2194,6 +2252,7 @@ def test_calculate_yesterday(my_predbat):
21942252
failed = _test_yesterday_slot_is_exporting(my_predbat, failed)
21952253
failed = _test_cross_charging_reconstructed_as_both_windows(my_predbat, failed)
21962254
failed = _test_slot_status_read_at_the_right_minute(my_predbat, failed)
2255+
failed = _test_recorded_hold_for_car_minutes(my_predbat, failed)
21972256
failed = _test_mixed_slot_keeps_most_active_state(my_predbat, failed)
21982257
failed = _test_more_active_slot_status(my_predbat, failed)
21992258
failed = _test_short_export_inside_a_freeze_slot(my_predbat, failed)

0 commit comments

Comments
 (0)