From e5d70aa68df8a21b78ebb361ce047972613ca798 Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 2 Oct 2026 17:02:59 +0100 Subject: [PATCH 1/2] Show the snail icon on low power charge slots in the plan (#5352) Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/output.py | 15 ++++++++++++--- apps/predbat/tests/test_plan_why_reason.py | 19 +++++++++++++++++++ docs/predbat-plan-card.md | 4 +++- 3 files changed, 34 insertions(+), 4 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 581000e08..97d093fda 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -1076,6 +1076,10 @@ def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_star 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()). + + Returns (rate_kw, low_power): the rate achieved after the charge curve, and whether low power + charging asked for less than the maximum charge rate. The curve tapering a full rate charge + near the top of the battery is not low power - only the requested rate decides that. """ window = self.charge_window_best[charge_window_n] soc = self.predict_soc_best.get(minute_relative_start, self.soc_kw) @@ -1083,7 +1087,7 @@ def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_star if self.set_charge_low_power: 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( + charge_rate_now, charge_rate_now_curve = find_charge_rate( minute_start, soc, window, @@ -1103,7 +1107,9 @@ def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_star low_power_pv_threshold_w=self.low_power_pv_threshold_w, solar_full_rate=self.set_charge_low_power_solar_full_rate, ) - return dp2(charge_rate_now_curve * MINUTE_WATT / 1000.0) + # Compared in whole watts, find_charge_rate() steps the rate down in watts and converts back + low_power = dp0(charge_rate_now * MINUTE_WATT) < dp0(self.battery_rate_max_charge * MINUTE_WATT) + return dp2(charge_rate_now_curve * MINUTE_WATT / 1000.0), low_power 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): """ @@ -1440,8 +1446,11 @@ def import_rate_color(rate): state = "Chrg↗" state_color = "#3AEE85" raw_state = "Chrg" - rate_kw = self.get_charge_rate_kw(charge_window_n, minute_start, minute_relative_start, pv_forecast_minute_step) + rate_kw, charge_low_power = 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 charge_low_power: + # Snail symbol + state += "🐌" if self.charge_window_best[charge_window_n]["start"] in self.manual_charge_times: state += " ⅎ" diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index 08f359bdb..e56855496 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -400,6 +400,9 @@ def render(car_hold_minutes=None): 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 + elif "🐌" in row["state_html"]: + print("ERROR: a full rate Chrg slot should not show the snail, got: {}".format(row["state_html"])) + failed = True # --- Test 2: HoldChrg --- print("Test HoldChrg reason") @@ -469,6 +472,22 @@ def render(car_hold_minutes=None): 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 + elif row["state_html"] != "Chrg↗🐌": + print("ERROR: Chrg low power state cell should show the snail like a slow export does (#5352), got: {}".format(row["state_html"])) + failed = True + + # --- Test 4c: low power mode on but the target needs the full rate - not a slow charge, no snail --- + print("Test Chrg low power at the full rate shows no snail") + my_predbat.charge_limit_best = [10.0] + my_predbat.predict_soc_best = _flat_soc(my_predbat, 2.0) # too far from the target to throttle within the window + _, raw_plan = render() + row = _get_row(raw_plan, minutes_now) + if row is None or _codes(row) != ["charge_low_rate"]: + print("ERROR: Chrg low power full rate reasons unexpected: {}".format(row and _codes(row))) + failed = True + elif "🐌" in row["state_html"]: + print("ERROR: Chrg low power at the full rate should not show the snail, got: {}".format(row["state_html"])) + failed = True my_predbat.set_charge_low_power = False my_predbat.charge_window_best = window my_predbat.charge_limit_best = [8.0] diff --git a/docs/predbat-plan-card.md b/docs/predbat-plan-card.md index b98b08677..1de9d5626 100644 --- a/docs/predbat-plan-card.md +++ b/docs/predbat-plan-card.md @@ -111,7 +111,9 @@ charging and discharging activity - if Predbat plans this, the state will show a - **Limit %** - Alongside any battery activity (charging, discharging, etc) there will be a SoC limit. This limit is what the SoC is planned to be at the end of the slot. e.g. 'Charge↗ 70%' is charge to 70% SoC, and 'Exp↘ 4%' is force exporting the battery to the 4% reserve level.
-If Predbat is planning a slow charge or slow export then the limit will be preceded by a snail symbol (🐌) and will be shown as 'limit.tens_of_percentage_rate_reduction'. e.g.: 16.3 means limit of 16% and charge/discharge at 70% of normal rate. +If Predbat is planning a slow charge or slow export then the State will be followed by a snail symbol (🐌).
+For a slow export, with plan debug enabled, the limit will also be shown as 'limit.tens_of_percentage_rate_reduction'. e.g.: 16.3 means limit of 16% and discharge at 70% of normal rate.
+For a slow charge ([low power charging mode](customisation.md#inverter-control-options)) the rate is worked out as the slot runs, so the planned rate is shown in the slot's description instead. - **PV kWh** - The predicted solar forecast for the half-hour slot, estimated from the [Solcast Forecast](apps-yaml.md#solcast-solar-forecast).
If the PV forecast is above 0.2kWh for the slot it will be coloured Melon Red with a little sun symbol, above 0.1kWh it will be Yellow with a sun symbol, From 432057206874170a53ca226901074ffded69f732 Mon Sep 17 00:00:00 2001 From: CI Date: Fri, 2 Oct 2026 17:17:15 +0100 Subject: [PATCH 2/2] Match the prediction engine's hybrid charge ceiling and floored PV window in the plan's charge rate Co-Authored-By: Claude Opus 5.5 (1M context) --- apps/predbat/output.py | 14 +++++-- apps/predbat/tests/test_plan_why_reason.py | 48 ++++++++++++++++++++++ 2 files changed, 59 insertions(+), 3 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 97d093fda..e130b29cd 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -1080,19 +1080,27 @@ def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_star Returns (rate_kw, low_power): the rate achieved after the charge curve, and whether low power charging asked for less than the maximum charge rate. The curve tapering a full rate charge near the top of the battery is not low power - only the requested rate decides that. + + The maximum is the one the prediction engine uses at the start of the slot: a hybrid inverter + whose DC charge rate is above its AC one can take the PV above the AC rate on top of it. """ window = self.charge_window_best[charge_window_n] soc = self.predict_soc_best.get(minute_relative_start, self.soc_kw) + battery_rate_max_charge = self.battery_rate_max_charge + if self.inverter_hybrid and (self.battery_rate_max_charge_dc > battery_rate_max_charge): + pv_above = max((pv_forecast_minute_step.get(minute_relative_start, 0.0) / PREDICT_STEP) - battery_rate_max_charge, 0) + battery_rate_max_charge += min(self.battery_rate_max_charge_dc - battery_rate_max_charge, pv_above) pv_window_kwh = 0.0 if self.set_charge_low_power: - window_end_rel = min(window["end"] - self.minutes_now, self.forecast_minutes) + # The window end is floored to a step boundary, as the prediction engine does + window_end_rel = min(max(((window["end"] - self.minutes_now) // PREDICT_STEP) * PREDICT_STEP, minute_relative_start), 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, charge_rate_now_curve = find_charge_rate( minute_start, soc, window, self.charge_limit_best[charge_window_n], - self.battery_rate_max_charge, + battery_rate_max_charge, self.soc_max, self.battery_charge_power_curve, self.set_charge_low_power, @@ -1108,7 +1116,7 @@ def get_charge_rate_kw(self, charge_window_n, minute_start, minute_relative_star solar_full_rate=self.set_charge_low_power_solar_full_rate, ) # Compared in whole watts, find_charge_rate() steps the rate down in watts and converts back - low_power = dp0(charge_rate_now * MINUTE_WATT) < dp0(self.battery_rate_max_charge * MINUTE_WATT) + low_power = dp0(charge_rate_now * MINUTE_WATT) < dp0(battery_rate_max_charge * MINUTE_WATT) return dp2(charge_rate_now_curve * MINUTE_WATT / 1000.0), low_power 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): diff --git a/apps/predbat/tests/test_plan_why_reason.py b/apps/predbat/tests/test_plan_why_reason.py index e56855496..5a74fdd1f 100644 --- a/apps/predbat/tests/test_plan_why_reason.py +++ b/apps/predbat/tests/test_plan_why_reason.py @@ -454,6 +454,10 @@ def render(car_hold_minutes=None): # 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}] + saved_predict_soc_best = my_predbat.predict_soc_best + saved_low_power_pv_threshold_w = my_predbat.low_power_pv_threshold_w + saved_inverter_hybrid = my_predbat.inverter_hybrid + saved_battery_rate_max_charge_dc = my_predbat.battery_rate_max_charge_dc 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) @@ -488,7 +492,51 @@ def render(car_hold_minutes=None): elif "🐌" in row["state_html"]: print("ERROR: Chrg low power at the full rate should not show the snail, got: {}".format(row["state_html"])) failed = True + + # --- Test 4d: PV that only arrives after the window's last whole step must not abandon low power, + # the prediction engine floors the window end to a step boundary and so never counts it --- + print("Test Chrg low power ignores PV past the floored window end") + my_predbat.charge_window_best = [{"start": minutes_now, "end": minutes_now + 62, "average": 10.0}] + my_predbat.charge_limit_best = [8.0] + my_predbat.predict_soc_best = _flat_soc(my_predbat, 7.9) + my_predbat.low_power_pv_threshold_w = 100 + pv_step[60] = 0.25 # 3kW for the step starting at the floored window end + _, raw_plan = render() + row = _get_row(raw_plan, minutes_now) + if row is None or _codes(row) != ["charge_low_rate"]: + print("ERROR: Chrg low power floored window reasons unexpected: {}".format(row and _codes(row))) + failed = True + elif "🐌" not in row["state_html"]: + print("ERROR: Chrg low power should stay throttled when the only PV is past the floored window end, got: {}".format(row["state_html"])) + failed = True + pv_step[60] = 0 + my_predbat.low_power_pv_threshold_w = saved_low_power_pv_threshold_w my_predbat.set_charge_low_power = False + + # --- Test 4e: hybrid inverter with a DC charge rate above the AC one charges through bright PV at + # the combined rate, as the prediction engine does - that is the maximum, so no snail --- + print("Test Chrg on a hybrid inverter in bright PV shows the combined AC+DC rate") + my_predbat.charge_window_best = low_power_window + my_predbat.charge_limit_best = [10.0] + my_predbat.predict_soc_best = _flat_soc(my_predbat, 2.0) + my_predbat.inverter_hybrid = True + my_predbat.battery_rate_max_charge_dc = my_predbat.battery_rate_max_charge * 2 + pv_step[0] = my_predbat.battery_rate_max_charge * 3 * 5 # PV at three times the AC charge rate + _, raw_plan = render() + row = _get_row(raw_plan, minutes_now) + if row is None or _codes(row) != ["charge_low_rate"]: + print("ERROR: hybrid Chrg reasons unexpected: {}".format(row and _codes(row))) + failed = True + elif row["reasons"][0]["params"]["rate_kw"] != "{:.2f}".format(max_rate_kw * 2): + print("ERROR: hybrid Chrg rate_kw should be the combined {}kW, got {}".format(max_rate_kw * 2, row["reasons"][0]["params"]["rate_kw"])) + failed = True + elif "🐌" in row["state_html"]: + print("ERROR: hybrid Chrg at the combined rate should not show the snail, got: {}".format(row["state_html"])) + failed = True + pv_step[0] = 0 + my_predbat.inverter_hybrid = saved_inverter_hybrid + my_predbat.battery_rate_max_charge_dc = saved_battery_rate_max_charge_dc + my_predbat.predict_soc_best = saved_predict_soc_best my_predbat.charge_window_best = window my_predbat.charge_limit_best = [8.0]