|
34 | 34 | "demand_falling": "Demand — the battery is expected to discharge to cover house load; no charging or exporting is scheduled this slot.", |
35 | 35 | "demand_steady": "Demand — battery level is expected to stay steady; no charging or exporting is scheduled this slot.", |
36 | 36 | "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.", |
37 | 39 | # Used for the first half of a split slot where the export window only starts partway through - |
38 | 40 | # deliberately worded without the "nothing is scheduled this slot" clause of the plain demand |
39 | 41 | # 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 |
1103 | 1105 | ) |
1104 | 1106 | return dp2(charge_rate_now_curve * MINUTE_WATT / 1000.0) |
1105 | 1107 |
|
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): |
1107 | 1109 | """ |
1108 | 1110 | 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()). |
1109 | 1115 | """ |
1110 | 1116 | html = "" |
1111 | 1117 | plan_debug = self.plan_debug |
@@ -1321,14 +1327,9 @@ def import_rate_color(rate): |
1321 | 1327 | else: |
1322 | 1328 | soc_sym = "↘" |
1323 | 1329 |
|
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) |
1332 | 1333 |
|
1333 | 1334 | state = "🚗" if holding_for_car else soc_sym |
1334 | 1335 | state_color = "#FFFFFF" |
@@ -1462,13 +1463,13 @@ def import_rate_color(rate): |
1462 | 1463 | # #5147). Same "car icon replaces the trend arrow/reason" convention as the |
1463 | 1464 | # whole-row case above, so this takes priority over the trend below rather |
1464 | 1465 | # 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) |
1466 | 1467 |
|
1467 | 1468 | 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) |
1468 | 1469 | split_time_str = (self.midnight_utc + timedelta(minutes=start)).strftime("%H:%M") |
1469 | 1470 | if holding_for_car_segment: |
1470 | 1471 | state = "🚗" |
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}}) |
1472 | 1473 | # Same near-flat tolerance as the whole-slot demand arrow above - testing |
1473 | 1474 | # soc_change_this >= 0 first would make the steady case unreachable and |
1474 | 1475 | # render a flat pre-window period as rising |
@@ -3165,6 +3166,18 @@ def yesterday_reconstruct_car_slots(self, end_record, yesterday_load_step, minut |
3165 | 3166 | load_value = yesterday_load_step.get(minute, 0) |
3166 | 3167 | yesterday_load_step[minute] = max(load_value - subtract_amount, 0) |
3167 | 3168 |
|
| 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 | + |
3168 | 3181 | def calculate_yesterday(self): |
3169 | 3182 | """ |
3170 | 3183 | Calculate the base plan for yesterday |
@@ -3430,13 +3443,17 @@ def calculate_yesterday(self): |
3430 | 3443 | self.predict_metric_best = cost_yesterday_array |
3431 | 3444 |
|
3432 | 3445 | # Fake charge/export windows based on previous predbat status |
| 3446 | + car_hold_minutes = set() |
3433 | 3447 | if predbat_status_data: |
3434 | 3448 | predbat_status = minute_data_state(predbat_status_data[0], 2, self.now_utc, "state", "last_updated") |
3435 | 3449 | for minute in predbat_status: |
3436 | 3450 | status = predbat_status[minute] |
3437 | 3451 | if "," in status: |
3438 | 3452 | # If there are multiple statuses take the first one |
3439 | 3453 | 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"} |
3440 | 3457 | # Ignore the first and last edge_minutes of each slot when they don't hold one state |
3441 | 3458 | # throughout - Predbat's reported status can lag a slot boundary by a minute or two while |
3442 | 3459 | # 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): |
3542 | 3559 |
|
3543 | 3560 | # Simulate yesterday with actual charge/export windows |
3544 | 3561 | 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 | + ) |
3546 | 3565 | self.forecast_minutes = end_record |
3547 | 3566 |
|
3548 | 3567 | # Restore state |
|
0 commit comments