From ec8812ab2632b105517f5d800e818eaaaceaf3a3 Mon Sep 17 00:00:00 2001 From: Rik Allen Date: Wed, 19 Aug 2026 23:06:05 +0100 Subject: [PATCH 1/2] feat(plan): show the actual planned charge/export rate in the hover text Adds {rate_kw} to the charge_low_rate and export_high_rate "why" reason templates, so the plan tooltip shows what rate will actually be used, not just the price. Requested in #4596, gcoan agreed hover text over cluttering the state cell. Export reuses the existing fractional-limit encoding (the same one that drives the snail symbol) - battery_rate_max_export scaled by 1 - (limit - int(limit)). Charging has no equivalent fixed encoding: low power charging (set_charge_low_power) throttles the rate dynamically per-minute rather than storing a reduced rate per window, so the non-low-power case is a straight nameplate-rate lookup but the low-power case needs recomputing via find_charge_rate() (added get_charge_rate_kw()) to avoid showing a misleadingly-high number during a throttled slot. Co-Authored-By: Claude Sonnet 5 --- apps/predbat/output.py | 47 +++++++++++++++++--- apps/predbat/tests/test_plan_why_reason.py | 50 ++++++++++++++++++++-- 2 files changed, 87 insertions(+), 10 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 10b7be77a..99dfd5cab 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -20,8 +20,8 @@ import copy from datetime import datetime, timedelta from config import THIS_VERSION -from const import TIME_FORMAT, PREDICT_STEP -from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state +from const import TIME_FORMAT, PREDICT_STEP, MINUTE_WATT +from utils import dp0, dp1, dp2, dp3, calc_percent_limit, minute_data, minute_data_state, find_charge_rate from prediction import Prediction # Per-slot plan "why" reason templates. Keyed by a stable reason code, each template is @@ -40,10 +40,10 @@ "demand_before_export_steady": "Until {split_time}, the battery level is expected to stay steady.", "freeze_charge": "Freeze charging — the battery holds at the current level rather than charging further this slot (import rate {rate}p/kWh vs. the calculated {threshold}p/kWh threshold).", "hold_charge_at_target": "Holding — the battery is already predicted to be at or above the {target_percent}% target for this window without charging further.", - "charge_low_rate": "Charging up to {target_percent}% at the import rate for this slot of ({rate}p/kWh).", + "charge_low_rate": "Charging up to {target_percent}% at {rate_kw}kW at the import rate for this slot of ({rate}p/kWh).", "freeze_export": "Freezing export — solar surplus passes straight to the grid, but it's not worth discharging the battery to sell more this slot.", "hold_export_unreachable": "Export window active but not triggered — the battery isn't predicted to reach the {target_percent}% level needed to export this slot.", - "export_high_rate": "Exporting down to {target_percent}% at the export rate of ({rate}p/kWh) using stored energy back to the grid.", + "export_high_rate": "Exporting down to {target_percent}% at {rate_kw}kW at the export rate of ({rate}p/kWh) using stored energy back to the grid.", "manual_override_charge": "You manually set this slot to charge.", "manual_override_freeze_charge": "You manually set this slot to freeze charging.", "manual_override_export": "You manually set this slot to export.", @@ -997,6 +997,38 @@ def short_textual_plan(self, soc_min, soc_min_minute, pv_forecast_minute_step, p return sentence + def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_start, pv_forecast_minute_step): + """ + Work out the actual planned charge rate (kW) for a charge_window_best slot. Unlike export, + which stores a fixed reduced rate in the fractional part of export_limits_best, low power + charging (set_charge_low_power) throttles the rate dynamically minute-by-minute, so it has + to be recomputed the same way the prediction engine works it out (find_charge_rate()). + """ + window = self.charge_window_best[charge_window_n] + soc = self.predict_soc_best.get(minute_relative_start, self.soc_kw) + pv_window_kwh = 0.0 + if self.set_charge_low_power: + pv_window_kwh = sum(pv_forecast_minute_step.get(m, 0.0) for m in range(minute_start, window["end"], PREDICT_STEP)) + _, charge_rate_now_curve = find_charge_rate( + minute_start, + soc, + window, + self.charge_limit_best[charge_window_n], + self.battery_rate_max_charge, + self.soc_max, + self.battery_charge_power_curve, + self.set_charge_low_power, + self.charge_low_power_margin, + self.battery_rate_min, + self.battery_rate_max_scaling, + self.battery_loss, + None, + self.battery_temperature, + self.battery_temperature_charge_curve, + pv_window_kwh=pv_window_kwh, + ) + return dp2(charge_rate_now_curve * MINUTE_WATT / 1000.0) + 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): """ Publish the current plan in HTML format @@ -1318,7 +1350,8 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, state = "Chrg↗" state_color = "#3AEE85" raw_state = "Chrg" - reason_parts.append({"code": "charge_low_rate", "params": {"target_percent": limit_percent, "rate": rate_text_import}}) + rate_kw = self.get_charge_rate_kw(charge_window_n, minute_start, minute_relative_start, pv_forecast_minute_step) + reason_parts.append({"code": "charge_low_rate", "params": {"target_percent": limit_percent, "rate": rate_text_import, "rate_kw": "{:.2f}".format(rate_kw)}}) if self.charge_window_best[charge_window_n]["start"] in self.manual_charge_times: state += " ⅎ" @@ -1388,7 +1421,9 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, else: state += "Exp↘" raw_state = "Exp" - reason_parts.append({"code": "export_high_rate", "params": {"target_percent": dp2(target), "rate": rate_text_export}}) + export_rate_adjust = 1 - (limit - int(limit)) + rate_kw = dp2(self.battery_rate_max_export * export_rate_adjust * MINUTE_WATT / 1000.0) + reason_parts.append({"code": "export_high_rate", "params": {"target_percent": dp2(target), "rate": rate_text_export, "rate_kw": "{:.2f}".format(rate_kw)}}) show_limit = str(dp2(target)) raw_state_target = str(dp2(target)) diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index 506f0b807..7534d2af7 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -132,13 +132,14 @@ def render(): _, raw_plan = render() templates = raw_plan["reason_templates"] row = _get_row(raw_plan, minutes_now) + expected_charge_rate_kw = "{:.2f}".format(my_predbat.battery_rate_max_charge * 60) if row is None or _codes(row) != ["charge_low_rate"]: print("ERROR: Chrg reasons unexpected: {}".format(row and _codes(row))) failed = True - elif row["reasons"][0]["params"] != {"target_percent": 80, "rate": "{:.2f}".format(row["import_rate"])}: + elif row["reasons"][0]["params"] != {"target_percent": 80, "rate": "{:.2f}".format(row["import_rate"]), "rate_kw": expected_charge_rate_kw}: print("ERROR: Chrg params unexpected: {}".format(row["reasons"][0]["params"])) failed = True - elif "Charging up to 80" not in _render(row, templates): + elif "Charging up to 80" not in _render(row, templates) or "{}kW".format(expected_charge_rate_kw) not in _render(row, templates): print("ERROR: Chrg rendered text unexpected: {}".format(_render(row, templates))) failed = True @@ -188,6 +189,32 @@ def render(): failed = True my_predbat.manual_charge_times = [] + # --- Test 4b: Chrg low power (set_charge_low_power throttles the rate below max, computed + # dynamically via find_charge_rate() rather than a fixed fraction like export's snail encoding) --- + print("Test Chrg low power reason shows the throttled rate_kw, not the nameplate max") + low_power_window = [{"start": minutes_now, "end": minutes_now + 60, "average": 10.0}] + my_predbat.charge_window_best = low_power_window + my_predbat.charge_limit_best = [8.0] # small gap above the 7.9 current SoC - easily reached even throttled + my_predbat.predict_soc_best = _flat_soc(my_predbat, 7.9) + my_predbat.set_charge_low_power = True + my_predbat.charge_low_power_margin = 10 + _, raw_plan = render() + row = _get_row(raw_plan, minutes_now) + max_rate_kw = my_predbat.battery_rate_max_charge * 60 + rate_kw = float(row["reasons"][0]["params"]["rate_kw"]) if row is not None and _codes(row) == ["charge_low_rate"] else None + if row is None or _codes(row) != ["charge_low_rate"]: + print("ERROR: Chrg low power reasons unexpected: {}".format(row and _codes(row))) + failed = True + elif rate_kw is None or rate_kw >= max_rate_kw: + print("ERROR: Chrg low power rate_kw not throttled below the {}kW max: got {}".format(max_rate_kw, rate_kw)) + failed = True + elif "{:.2f}kW".format(rate_kw) not in _render(row, templates): + print("ERROR: Chrg low power rendered text missing the throttled rate: {}".format(_render(row, templates))) + failed = True + my_predbat.set_charge_low_power = False + my_predbat.charge_window_best = window + my_predbat.charge_limit_best = [8.0] + # --- Test 5: Exp --- print("Test Exp reason") my_predbat.charge_window_best = [] @@ -197,16 +224,31 @@ def render(): my_predbat.predict_soc_best = _flat_soc(my_predbat, 9.0) # 90%, well above the 50% target _, raw_plan = render() row = _get_row(raw_plan, minutes_now) + expected_export_rate_kw = "{:.2f}".format(my_predbat.battery_rate_max_export * 60) if row is None or _codes(row) != ["export_high_rate"]: print("ERROR: Exp reasons unexpected: {}".format(row and _codes(row))) failed = True - elif row["reasons"][0]["params"] != {"target_percent": 50.0, "rate": "{:.2f}".format(row["export_rate"])}: + elif row["reasons"][0]["params"] != {"target_percent": 50.0, "rate": "{:.2f}".format(row["export_rate"]), "rate_kw": expected_export_rate_kw}: print("ERROR: Exp params unexpected: {}".format(row["reasons"][0]["params"])) failed = True - elif "Exporting down to" not in _render(row, templates): + elif "Exporting down to" not in _render(row, templates) or "{}kW".format(expected_export_rate_kw) not in _render(row, templates): print("ERROR: Exp rendered text unexpected: {}".format(_render(row, templates))) failed = True + # --- Test 5b: Exp slow (fractional limit -> reduced rate, the snail-symbol encoding) --- + print("Test Exp slow reason shows the reduced rate_kw, not the nameplate max") + my_predbat.export_limits_best = [50.3] # limit.tens_of_percentage_rate_reduction -> 70% of max rate + _, raw_plan = render() + row = _get_row(raw_plan, minutes_now) + expected_slow_export_rate_kw = "{:.2f}".format(my_predbat.battery_rate_max_export * 60 * 0.7) + if row is None or _codes(row) != ["export_high_rate"]: + print("ERROR: Exp slow reasons unexpected: {}".format(row and _codes(row))) + failed = True + elif row["reasons"][0]["params"].get("rate_kw") != expected_slow_export_rate_kw: + print("ERROR: Exp slow rate_kw unexpected: expected {}, got {}".format(expected_slow_export_rate_kw, row["reasons"][0]["params"].get("rate_kw"))) + failed = True + my_predbat.export_limits_best = [50.0] + # --- Test 6: HoldExp --- print("Test HoldExp reason") my_predbat.export_limits_best = [95.0] # 95% target, unreachable this window From b3951136f9105461ab1aa6c833940128252b02f3 Mon Sep 17 00:00:00 2001 From: Rik Allen <48563392+chalfontchubby@users.noreply.github.com> Date: Thu, 20 Aug 2026 08:27:19 +0100 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/output.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 99dfd5cab..e0eb3fc81 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -1008,7 +1008,8 @@ def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_star soc = self.predict_soc_best.get(minute_relative_start, self.soc_kw) pv_window_kwh = 0.0 if self.set_charge_low_power: - pv_window_kwh = sum(pv_forecast_minute_step.get(m, 0.0) for m in range(minute_start, window["end"], PREDICT_STEP)) + window_end_rel = min(window["end"] - self.minutes_now, self.forecast_minutes) + pv_window_kwh = sum(pv_forecast_minute_step.get(m, 0.0) for m in range(minute_relative_start, window_end_rel, PREDICT_STEP)) _, charge_rate_now_curve = find_charge_rate( minute_start, soc,