Skip to content

Commit badd8c5

Browse files
fix(plan): skip low power charging when the charge window overlaps solar
The planner costs every charge window at the full charge rate - low power charging is only applied to the final plan (prediction.py gates it on save in best/best10/test). find_charge_rate then sizes the throttled rate from charge_left / minutes_left with no knowledge of PV. In a charge window that overlaps solar production the rate cap in battery_draw = -max(min(charge_rate_now_curve_step, ...), 0, -battery_to_max) stops the PV reaching the battery. The surplus is exported at the export rate and the charge target is then made up from grid import once the sun has gone, costing more than the full rate charge that was planned for. find_charge_rate now returns the max rate when the PV forecast summed across the remainder of the charge window exceeds LOW_POWER_PV_THRESHOLD (0.1kWh). Both callers supply that sum: the prediction from a suffix sum of the PV step array built once per run (only when low power is active, so the optimiser hot loop is untouched), and execute from pv_forecast_minute between now and the window end. No C++ kernel change is needed - the kernel only runs when save is falsy, which is exactly when charge low power is off, so it already takes the max rate path and stays in parity. Tests: model scenarios low_power_pv_* / low_power_dark_* (without the fix the low power run costs 44p against a 0p full rate plan), execute scenarios charge_low_power_pv / charge_low_power_pv_trace, and a find_charge_rate unit test for the threshold either side. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent dd136f9 commit badd8c5

10 files changed

Lines changed: 197 additions & 6 deletions

File tree

apps/predbat/const.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,12 @@
4040
MAX_INCREMENT = 240 * 100 * 3 / 1000 / 60
4141
MINUTE_WATT = 60 * 1000
4242

43+
# PV production (kWh) forecast across the remainder of a charge window above which low power charging is
44+
# abandoned in favour of the max charge rate. Throttling the charge rate while the sun is shining stops the
45+
# PV reaching the battery, the surplus is exported cheaply and the target is then made up with grid import,
46+
# which increases the cost of the plan over the full rate charge the planner costed the window at.
47+
LOW_POWER_PV_THRESHOLD = 0.1
48+
4349
INVERTER_TEST = False # Run inverter control self test
4450

4551
# Create an array of times in the day in 5-minute intervals

apps/predbat/execute.py

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,13 @@ def execute_plan(self):
130130
inv_target_soc_percent = self.adjust_battery_target_multi(inverter, target_soc, True, False, check=True, isFreezeCharge=is_freeze_charge)
131131

132132
current_charge_rate = inverter.get_current_charge_rate()
133+
134+
# How much PV is still forecast before this charge window closes?
135+
pv_window_kwh = 0.0
136+
if self.set_charge_low_power:
137+
for pv_minute in range(self.minutes_now, window["end"]):
138+
pv_window_kwh += self.pv_forecast_minute.get(pv_minute, 0.0)
139+
133140
new_charge_rate, new_charge_rate_real = find_charge_rate(
134141
self.minutes_now,
135142
inverter.soc_kw,
@@ -147,6 +154,7 @@ def execute_plan(self):
147154
inverter.battery_temperature,
148155
self.battery_temperature_charge_curve,
149156
current_charge_rate=current_charge_rate / MINUTE_WATT,
157+
pv_window_kwh=pv_window_kwh,
150158
)
151159
new_charge_rate = int(new_charge_rate * MINUTE_WATT)
152160

apps/predbat/prediction.py

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -588,6 +588,15 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
588588
pv_forecast_minute_step_flat = pv_forecast_minute_step
589589
load_minutes_step_flat = load_minutes_step
590590

591+
# PV forecast remaining from each step to the end of the forecast, used to work out how much PV a charge
592+
# window still overlaps with as low power charging must be abandoned when the sun is contributing
593+
pv_remaining_kwh = {}
594+
if set_charge_low_power:
595+
pv_remaining = 0.0
596+
for minute_step in range(((self.forecast_minutes - 1) // step) * step, -1, -step):
597+
pv_remaining += pv_forecast_minute_step_flat.get(minute_step, 0.0)
598+
pv_remaining_kwh[minute_step] = pv_remaining
599+
591600
# Simulate each forward minute
592601
minute = 0
593602
while minute < self.forecast_minutes:
@@ -932,6 +941,13 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
932941
battery_rate_max_charge_combined = battery_rate_max_charge + min(battery_rate_max_charge_dc - battery_rate_max_charge, pv_above)
933942
else:
934943
battery_rate_max_charge_combined = battery_rate_max_charge
944+
945+
# How much PV is still to come before this charge window closes?
946+
pv_window_kwh = 0.0
947+
if set_charge_low_power:
948+
window_end_step = min(max(((charge_window[charge_window_n]["end"] - self.minutes_now) // step) * step, minute), self.forecast_minutes)
949+
pv_window_kwh = pv_remaining_kwh.get(minute, 0.0) - pv_remaining_kwh.get(window_end_step, 0.0)
950+
935951
charge_rate_now, charge_rate_now_curve = find_charge_rate(
936952
minute_absolute,
937953
soc,
@@ -948,6 +964,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi
948964
None,
949965
battery_temperature,
950966
self.battery_temperature_charge_curve,
967+
pv_window_kwh=pv_window_kwh,
951968
)
952969
charge_rate_now_curve_step = charge_rate_now_curve * step
953970

apps/predbat/tests/test_execute.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -217,6 +217,7 @@ def run_execute_test(
217217
car_soc=0,
218218
battery_temperature=20,
219219
assert_immediate_charge_soc_freeze_array=[],
220+
pv_forecast=0.0,
220221
):
221222
print("> Run scenario {}".format(name))
222223
my_predbat.log("> Run scenario {}".format(name))
@@ -231,6 +232,8 @@ def run_execute_test(
231232
my_predbat.charge_low_power_margin = charge_low_power_margin
232233
my_predbat.minutes_now = minutes_now
233234
my_predbat.battery_temperature_charge_curve = {20: 1.0, 10: 0.5, 9: 0.5, 8: 0.5, 7: 0.5, 6: 0.3, 5: 0.3, 4: 0.3, 3: 0.262, 2: 0.1, 1: 0.1, 0: 0}
235+
# Flat PV forecast of pv_forecast kW, reset every scenario so a solar run does not leak into the next one
236+
my_predbat.pv_forecast_minute = {minute: pv_forecast / 60.0 for minute in range(minutes_now, minutes_now + my_predbat.forecast_minutes)}
234237

235238
charge_window_best = charge_window_best.copy()
236239
charge_limit_best = charge_limit_best.copy()
@@ -852,6 +855,49 @@ def run_execute_tests(my_predbat):
852855
if failed:
853856
return failed
854857

858+
# Same window, but 1kW of PV forecast across the 60 minutes (1kWh, over the 0.1kWh threshold).
859+
# Throttling would cap how much of that PV reaches the battery, so charge at the max rate instead
860+
failed |= run_execute_test(
861+
my_predbat,
862+
"charge_low_power_pv",
863+
charge_window_best=charge_window_best,
864+
charge_limit_best=charge_limit_best,
865+
assert_charge_time_enable=True,
866+
soc_kw=9,
867+
set_charge_window=True,
868+
set_export_window=True,
869+
set_charge_low_power=True,
870+
assert_status="Charging",
871+
assert_charge_start_time_minutes=-1,
872+
assert_charge_end_time_minutes=my_predbat.minutes_now + 60,
873+
assert_charge_rate=2000,
874+
battery_max_rate=2000,
875+
pv_forecast=1.0,
876+
)
877+
if failed:
878+
return failed
879+
880+
# A trace of PV (0.05kWh over the window) is under the threshold, low power charging still applies
881+
failed |= run_execute_test(
882+
my_predbat,
883+
"charge_low_power_pv_trace",
884+
charge_window_best=charge_window_best,
885+
charge_limit_best=charge_limit_best,
886+
assert_charge_time_enable=True,
887+
soc_kw=9,
888+
set_charge_window=True,
889+
set_export_window=True,
890+
set_charge_low_power=True,
891+
assert_status="Charging",
892+
assert_charge_start_time_minutes=-1,
893+
assert_charge_end_time_minutes=my_predbat.minutes_now + 60,
894+
assert_charge_rate=600,
895+
battery_max_rate=2000,
896+
pv_forecast=0.05,
897+
)
898+
if failed:
899+
return failed
900+
855901
# 60 minutes - 10 minute margin = 50 minutes to add 0.4kWh to each battery (x2 inverters)
856902
# (60 / 50) * 400 = 480
857903
failed |= run_execute_test(

apps/predbat/tests/test_find_charge_rate.py

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,68 @@ def test_find_charge_rate(my_predbat):
6868
return failed
6969

7070

71+
def test_find_charge_rate_pv_overlap(my_predbat):
72+
"""
73+
Test that low power charging is bypassed when the charge window overlaps with PV production
74+
75+
Throttling the charge rate while the sun is shining stops PV going into the battery, the surplus
76+
is exported cheaply and the target is then made up with grid import, which increases the plan cost.
77+
"""
78+
failed = 0
79+
80+
log_to = print
81+
minutes_now = my_predbat.minutes_now
82+
soc = 1.0
83+
soc_max = 10.0
84+
target_soc = 5.0
85+
# Plenty of time left in the window so low power mode has room to slow the charge right down
86+
window = {"start": minutes_now - 30, "end": minutes_now + 300}
87+
# Flat curves so the rate is not capped by the battery or its temperature
88+
battery_charge_power_curve = my_predbat.validate_curve({100: 1.0, 99: 1.0, 98: 1.0, 97: 1.0, 96: 1.0, 95: 1.0, 94: 1.0, 93: 1.0, 92: 1.0, 91: 1.0, 90: 1.0}, "test_flat_charge_curve")
89+
battery_temperature_curve = my_predbat.validate_curve({20: 1.0, 19: 1.0, 18: 1.0, 17: 1.0, 16: 1.0, 15: 1.0}, "test_flat_temperature_curve")
90+
max_rate = 3000
91+
92+
args = [
93+
minutes_now,
94+
soc,
95+
window,
96+
target_soc,
97+
max_rate / MINUTE_WATT,
98+
soc_max,
99+
battery_charge_power_curve,
100+
True,
101+
my_predbat.charge_low_power_margin,
102+
0,
103+
1,
104+
0.96,
105+
log_to,
106+
]
107+
kwargs = {"battery_temperature": 17.0, "battery_temperature_curve": battery_temperature_curve, "current_charge_rate": max_rate / MINUTE_WATT}
108+
109+
# Without PV low power mode should slow the charge down below the max rate
110+
dark_rate, dark_rate_real = find_charge_rate(*args, **kwargs)
111+
print("No PV - Best_rate {} Best_rate_real {}".format(dark_rate * MINUTE_WATT, dark_rate_real * MINUTE_WATT))
112+
if dark_rate * MINUTE_WATT >= max_rate:
113+
print("**** ERROR: Low power mode should reduce the rate below {}W when there is no PV, got {}W ****".format(max_rate, dark_rate * MINUTE_WATT))
114+
failed = 1
115+
116+
# A trace of PV in the window is not enough to matter, low power mode should still apply
117+
trace_rate, trace_rate_real = find_charge_rate(*args, pv_window_kwh=0.05, **kwargs)
118+
print("Trace PV - Best_rate {} Best_rate_real {}".format(trace_rate * MINUTE_WATT, trace_rate_real * MINUTE_WATT))
119+
if trace_rate != dark_rate:
120+
print("**** ERROR: 0.05kWh of PV in the window should not change the rate, expected {}W got {}W ****".format(dark_rate * MINUTE_WATT, trace_rate * MINUTE_WATT))
121+
failed = 1
122+
123+
# Real PV production in the window means we must charge at full rate so the PV is not throttled away
124+
sun_rate, sun_rate_real = find_charge_rate(*args, pv_window_kwh=2.0, **kwargs)
125+
print("With PV - Best_rate {} Best_rate_real {}".format(sun_rate * MINUTE_WATT, sun_rate_real * MINUTE_WATT))
126+
if sun_rate * MINUTE_WATT != max_rate:
127+
print("**** ERROR: PV production in the window should force the max rate {}W, got {}W ****".format(max_rate, sun_rate * MINUTE_WATT))
128+
failed = 1
129+
130+
return failed
131+
132+
71133
def test_find_charge_rate_string_temperature(my_predbat):
72134
"""
73135
Test find_charge_rate with string temperature indices in the curve

apps/predbat/tests/test_infra.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,7 @@ def simple_scenario(
624624
calculate_export_on_pv=True,
625625
assert_clipped=0,
626626
pv_ac_limit=0,
627+
pv_hours=None,
627628
):
628629
"""
629630
No PV, No Load
@@ -742,11 +743,11 @@ def simple_scenario(
742743
load10_step = {}
743744

744745
for minute in range(0, my_predbat.forecast_minutes, 5):
745-
pv_step[minute] = pv_amount / (60 / 5) if not pv10 else 0
746+
# pv_hours limits PV to the first N hours of the forecast, otherwise it runs at pv_amount all day
747+
pv_now = 0 if (pv_hours is not None and minute >= pv_hours * 60) else pv_amount
748+
pv_step[minute] = pv_now / (60 / 5) if not pv10 else 0
746749
load_step[minute] = load_amount / (60 / 5) if not pv10 else 0
747-
748-
for minute in range(0, my_predbat.forecast_minutes, 5):
749-
pv10_step[minute] = pv_amount / (60 / 5) if pv10 else 0
750+
pv10_step[minute] = pv_now / (60 / 5) if pv10 else 0
750751
load10_step[minute] = load_amount / (60 / 5) if pv10 else 0
751752

752753
if charge_car:

apps/predbat/tests/test_model.py

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2009,6 +2009,40 @@ def run_model_tests(my_predbat, prediction_kernel=False):
20092009
# pv_ac_limit must NOT apply to hybrid inverters (PV is DC-coupled, clipping handled by inverter_limit)
20102010
failed |= simple_scenario("pv_ac_limit_hybrid_ignored", my_predbat, 0, 2.0, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, hybrid=True, pv_ac_limit=1.5, assert_clipped=0)
20112011

2012+
# Low power charging must not make the plan more expensive when the charge window overlaps PV production.
2013+
# The planner costs every charge window at the full charge rate as low power is only applied to the final
2014+
# plan, so a throttled rate that caps how much PV reaches the battery pushes the cost above the plan.
2015+
reset_rates(my_predbat, import_rate, export_rate)
2016+
reset_inverter(my_predbat)
2017+
2018+
# 6kW of PV for the first 2 hours only, with an 8 hour charge window to 12kWh and a 6kW max charge rate.
2019+
# At full rate the PV alone fills the battery inside those 2 hours, costing nothing. Throttled to fit the
2020+
# 8 hour window the battery would take only 1.5kW, exporting the other 4.5kW of PV at 5p and then
2021+
# importing the missing 9kWh at 10p once the sun has gone - 45p worse than the planner costed it at.
2022+
low_power_pv = {
2023+
"load_amount": 0,
2024+
"pv_amount": 6.0,
2025+
"pv_hours": 2,
2026+
"charge": 12,
2027+
"charge_window_best": [{"start": my_predbat.minutes_now, "end": my_predbat.minutes_now + 480, "average": import_rate}],
2028+
"battery_size": 20,
2029+
"battery_soc": 0,
2030+
"battery_rate_max_charge": 6.0,
2031+
"inverter_limit": 10.0,
2032+
"export_limit": 10.0,
2033+
"assert_final_soc": 12,
2034+
"assert_final_metric": 0,
2035+
}
2036+
failed |= simple_scenario("low_power_pv_full_rate", my_predbat, set_charge_low_power=False, **low_power_pv)
2037+
failed |= simple_scenario("low_power_pv_low_power", my_predbat, set_charge_low_power=True, **low_power_pv)
2038+
2039+
# With no PV in the window low power charging still applies, the whole 12kWh comes from the grid either way
2040+
low_power_dark = dict(low_power_pv)
2041+
low_power_dark["pv_amount"] = 0
2042+
low_power_dark["assert_final_metric"] = import_rate * 12
2043+
failed |= simple_scenario("low_power_dark_full_rate", my_predbat, set_charge_low_power=False, **low_power_dark)
2044+
failed |= simple_scenario("low_power_dark_low_power", my_predbat, set_charge_low_power=True, **low_power_dark)
2045+
20122046
my_predbat.prediction_kernel_enable = False
20132047
if failed:
20142048
print("**** ERROR: Some Model tests failed ****")

apps/predbat/unit_test.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -74,7 +74,7 @@
7474
from tests.test_web_chart_grouping import run_web_chart_grouping_tests
7575
from tests.test_web_entity_unit_resolution import run_web_entity_unit_resolution_tests
7676
from tests.test_window import run_window_sort_tests, run_intersect_window_tests
77-
from tests.test_find_charge_rate import test_find_charge_rate, test_find_charge_rate_string_temperature, test_find_charge_rate_string_charge_curve
77+
from tests.test_find_charge_rate import test_find_charge_rate, test_find_charge_rate_pv_overlap, test_find_charge_rate_string_temperature, test_find_charge_rate_string_charge_curve
7878
from tests.test_manual_api import run_test_manual_api
7979
from tests.test_manual_soc import run_test_manual_soc
8080
from tests.test_manual_times import run_test_manual_times
@@ -301,6 +301,7 @@ def main():
301301
("rate_replicate", test_rate_replicate, "Rate replicate comprehensive tests (missing slots, IO, offsets, gas)", False),
302302
("find_charge_window", test_find_charge_window, "Find charge window gap handling tests", False),
303303
("find_charge_rate", test_find_charge_rate, "Find charge rate tests", False),
304+
("find_charge_rate_pv", test_find_charge_rate_pv_overlap, "Find charge rate with PV overlap", False),
304305
("find_charge_rate_string_temp", test_find_charge_rate_string_temperature, "Find charge rate string temperature", False),
305306
("find_charge_rate_string_curve", test_find_charge_rate_string_charge_curve, "Find charge rate string charge curve", False),
306307
("find_charge_curve", run_find_charge_curve_tests, "Find charge curve tests", False),

apps/predbat/utils.py

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
import array
2020
from datetime import datetime, timedelta, timezone, time
2121
from functools import lru_cache
22-
from const import MINUTE_WATT, PREDICT_STEP, TIME_FORMAT, TIME_FORMAT_SECONDS, TIME_FORMAT_OCTOPUS, MAX_INCREMENT, TIME_FORMAT_DAILY
22+
from const import LOW_POWER_PV_THRESHOLD, MINUTE_WATT, PREDICT_STEP, TIME_FORMAT, TIME_FORMAT_SECONDS, TIME_FORMAT_OCTOPUS, MAX_INCREMENT, TIME_FORMAT_DAILY
2323
import copy
2424

2525
DAY_OF_WEEK_MAP = {"mon": 0, "tue": 1, "wed": 2, "thu": 3, "fri": 4, "sat": 5, "sun": 6}
@@ -1150,9 +1150,14 @@ def find_charge_rate(
11501150
battery_temperature=20,
11511151
battery_temperature_curve={},
11521152
current_charge_rate=None,
1153+
pv_window_kwh=0.0,
11531154
):
11541155
"""
11551156
Find the lowest charge rate that fits the charge slow
1157+
1158+
pv_window_kwh is the PV forecast in kWh over the remainder of the charge window, when the window
1159+
overlaps PV production low power charging is abandoned as the throttled rate applies for the whole
1160+
window and would push the PV out of the battery, raising the cost above the planned full rate charge
11561161
"""
11571162
margin = charge_low_power_margin
11581163
target_soc = round(target_soc, 2)
@@ -1169,6 +1174,13 @@ def find_charge_rate(
11691174

11701175
min_battery_rate = max(400, int(round(battery_rate_min * MINUTE_WATT)))
11711176
if set_charge_low_power:
1177+
# If the charge window overlaps with PV production then charge at max rate, a throttled rate would
1178+
# cap the PV going into the battery, exporting the surplus and importing to make the target up later
1179+
if pv_window_kwh > LOW_POWER_PV_THRESHOLD:
1180+
if log_to:
1181+
log_to("Low power mode: PV forecast in window {}kWh > {}kWh, default to max rate".format(dp2(pv_window_kwh), LOW_POWER_PV_THRESHOLD))
1182+
return max_rate, max_rate_real
1183+
11721184
minutes_left = window["end"] - minutes_now - margin
11731185
abs_minutes_left = window["end"] - minutes_now
11741186

docs/customisation.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -382,6 +382,10 @@ as otherwise the low power charge may not reach the charge target in time.
382382
The minimum requested charge rate used in this mode is 400 watts (subject to inverter/battery minimum rate limits).
383383
This setting is off by default.
384384

385+
Low-power charging is skipped for any charge window that overlaps with forecast solar production, the full charge rate is used instead.
386+
Throttling the charge rate while the sun is shining would cap how much solar reaches the battery, the surplus would be exported at the
387+
export rate and the charge target then made up from grid import later, which costs more than the full rate charge Predbat planned for.
388+
385389
The YouTube video [low power charging and charging curve](https://youtu.be/L2vY_Vj6pQg?si=0ZiIVrDLHkeDCx7h)
386390
explains how the low-power charging works and shows how Predbat automatically creates it.
387391

0 commit comments

Comments
 (0)