diff --git a/apps/predbat/output.py b/apps/predbat/output.py
index 581000e08..e130b29cd 100644
--- a/apps/predbat/output.py
+++ b/apps/predbat/output.py
@@ -1076,19 +1076,31 @@ 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.
+
+ 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_curve = find_charge_rate(
+ 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,
@@ -1103,7 +1115,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(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 +1454,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..5a74fdd1f 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")
@@ -451,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)
@@ -469,7 +476,67 @@ 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
+
+ # --- 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]
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,