diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 6846d56e9..da360363c 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -816,9 +816,15 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi over_limit = abs(diff) - export_limit reduce_by = over_limit - if reduce_by > battery_draw: + # Compare the AC over-export against the battery's AC export contribution (battery_draw is DC, + # so that is battery_draw * inverter_loss). If the surplus is larger then even stopping the + # battery leaves PV over the limit, so we must charge to absorb it rather than clip the solar. + if reduce_by > battery_draw * inverter_loss: if self.inverter_can_charge_during_export: - reduce_by = reduce_by - battery_draw + # Stopping the battery only removes its AC export contribution (battery_draw is DC, so + # that is battery_draw * inverter_loss). Whatever AC export is still over the limit has + # to be absorbed by charging the battery. + reduce_by = reduce_by - battery_draw * inverter_loss if inverter_hybrid: charge_rate_now_curve_dc = ( @@ -826,13 +832,23 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi * battery_rate_max_scaling ) charge_rate_now_curve_dc_step = charge_rate_now_curve_dc * step - battery_draw = max(-reduce_by * inverter_loss, -battery_to_min, -charge_rate_now_curve_dc_step) + # Hybrid charges from PV on the DC side (see pv_dc below), so the AC surplus maps + # back to DC through the loss reciprocal. Clamp by battery_to_max (remaining charge + # headroom), not battery_to_min, otherwise a near-full battery is asked to absorb + # more than it can hold and the surplus is mis-accounted instead of clipped. + battery_draw = max(-reduce_by * inverter_loss_recp, -battery_to_max, -charge_rate_now_curve_dc_step) else: - battery_draw = max(-reduce_by * inverter_loss, -battery_to_min, -charge_rate_now_curve_step) + # Non-hybrid charges from the grid (AC), so the DC charge is the AC surplus * loss. + battery_draw = max(-reduce_by * inverter_loss, -battery_to_max, -charge_rate_now_curve_step) else: battery_draw = 0 else: - battery_draw = battery_draw - reduce_by + # reduce_by is an AC over-export figure but battery_draw is DC and exports through + # the inverter, so scale by the loss reciprocal to remove the right amount of grid + # export. Subtracting the raw AC figure under-reduces the battery and leaves a small + # residual that gets clipped off the solar later. Clamp at zero so we never flip to + # charging here (that case is handled by the inverter_can_charge_during_export branch). + battery_draw = max(battery_draw - reduce_by * inverter_loss_recp, 0) if inverter_hybrid and battery_draw < 0: pv_dc = min(abs(battery_draw), pv_now) @@ -853,7 +869,14 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi * battery_rate_max_scaling ) charge_rate_now_curve_dc_step = charge_rate_now_curve_dc * step - battery_draw = max(-reduce_by * inverter_loss, -battery_to_min, -charge_rate_now_curve_dc_step) + # reduce_by here is in the same DC-equivalent throughput units as total_inverted + # (get_total_inverted counts the battery and the PV diverted to DC 1:1), so the + # battery must charge by reduce_by directly to bring total_inverted onto the + # inverter limit - no inverter_loss factor. Multiplying by inverter_loss under- + # charges and leaves PV to be clipped that the battery could have absorbed. Clamp + # by battery_to_max (remaining charge headroom) so a near-full battery is not + # asked to absorb more than it can hold. + battery_draw = max(-reduce_by, -battery_to_max, -charge_rate_now_curve_dc_step) else: battery_draw = battery_draw - reduce_by diff --git a/apps/predbat/tests/test_model.py b/apps/predbat/tests/test_model.py index ee362c001..509c71783 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -409,6 +409,118 @@ def run_model_tests(my_predbat): failed |= simple_scenario("load_discharge_reserve", my_predbat, 1, 0, assert_final_metric=import_rate * 15, assert_final_soc=1, battery_soc=10.0, with_battery=True, reserve=1.0) failed |= simple_scenario("load_discharge_reserve2", my_predbat, 1, 0, assert_final_metric=import_rate * 20, assert_final_soc=2, battery_soc=10.0, with_battery=True, reserve=2.0, battery_loss=0.5) failed |= simple_scenario("load_discharge_loss", my_predbat, 1, 0, assert_final_metric=import_rate * 19, assert_final_soc=0, battery_soc=10.0, with_battery=True, battery_loss=0.5) + # Forced export with PV on a lossy hybrid inverter. The battery exports through the inverter (DC->AC) so + # when battery + solar would exceed the export limit the battery discharge must be scaled back by the loss + # reciprocal to bring grid export down to the limit. Otherwise a small residual is left above the limit and + # gets clipped off the solar. Regression test: with the scale-back correct, no solar should be clipped. + # battery_draw(DC) = (export_limit - pv_ac) / inverter_loss = (3 - 2*0.8) / 0.8 = 1.75 kW, over 24h = 42 kWh. + failed |= simple_scenario( + "export_pv_clip_loss", + my_predbat, + 0, + 2, + assert_final_metric=-export_rate * 24 * 3, + assert_final_soc=100 - 42, + battery_soc=100.0, + with_battery=True, + hybrid=True, + inverter_loss=0.8, + export_limit=3.0, + inverter_limit=10.0, + battery_rate_max_charge=5.0, + discharge=0, + assert_clipped=0, + ) + # Forced export with PV so large that even stopping the battery leaves the solar over the export limit. With + # inverter_can_charge_during_export the battery should charge from the surplus PV (DC side) to keep grid export + # at the limit, rather than clipping the solar. Regression test for the AC/DC unit mismatch in that charge branch. + # remaining_ac = pv_ac - export_limit = 2*0.8 - 1 = 0.6; hybrid DC charge = 0.6 / 0.8 = 0.75 kW, over 24h = 18 kWh. + failed |= simple_scenario( + "export_pv_charge_clip_loss", + my_predbat, + 0, + 2, + assert_final_metric=-export_rate * 24, + assert_final_soc=40 + 18, + battery_soc=40.0, + with_battery=True, + hybrid=True, + inverter_loss=0.8, + export_limit=1.0, + inverter_limit=10.0, + battery_rate_max_charge=1.0, + discharge=0, + inverter_can_charge_during_export=True, + assert_clipped=0, + ) + # Band case for the scale-back vs charge decision. The AC over-export (1.5kW) is larger than the battery's + # AC contribution (battery_draw 2kW DC * inverter_loss 0.5 = 1kW) but smaller than the raw DC discharge (2kW). + # The branch pivot must use the AC contribution: even after stopping the battery the 1kW AC PV is still over + # the 0.5kW export limit, so the battery should charge to absorb the 0.5kW surplus (0.25kW DC, 6kWh over 24h) + # instead of clipping it. Comparing against the raw DC value sends this to the scale-back path which just + # stops the battery and clips the solar. + failed |= simple_scenario( + "export_pv_charge_band_loss", + my_predbat, + 0, + 1, + assert_final_metric=-export_rate * 24 * 0.5, + assert_final_soc=50 + 0.25 * 24, + battery_soc=50.0, + with_battery=True, + inverter_loss=0.5, + export_limit=0.5, + inverter_limit=10.0, + battery_rate_max_charge=2.0, + discharge=0, + inverter_can_charge_during_export=True, + assert_clipped=0, + ) + # Full battery during a high-PV forced export. PV alone (2kW) exceeds the 0.5kW export limit so the charge + # path is entered, but the battery is already at 100% so it has no headroom to absorb anything. The charge + # must be clamped by battery_to_max (0 here) so all 1.5kW AC surplus is clipped. Clamping by battery_to_min + # instead would let the model "charge" a full battery and under-report the clipping (clip 12 instead of 36). + failed |= simple_scenario( + "export_pv_charge_full_battery", + my_predbat, + 0, + 2, + assert_final_metric=-export_rate * 24 * 0.5, + assert_final_soc=100, + battery_soc=100.0, + with_battery=True, + export_limit=0.5, + inverter_limit=10.0, + battery_rate_max_charge=1.0, + discharge=0, + inverter_can_charge_during_export=True, + assert_clipped=24 * 1.5, + ) + # Hybrid forced export where PV (4kW DC) exceeds the inverter limit (2kW) but the grid export limit is not + # binding, so the inverter-limit charge branch absorbs the surplus PV into the battery. total_inverted counts + # the battery and the DC-diverted PV 1:1, so the battery must charge by reduce_by = pv - inverter_limit = 2kW + # (not reduce_by * inverter_loss). Charging the full 2kW DC keeps total_inverted exactly on the 2kW limit with + # no clipping; charging only 1.6kW (the under-charge bug) leaves total_inverted at 2.4kW and clips 0.4kW of PV. + failed |= simple_scenario( + "export_pv_inverter_limit_charge", + my_predbat, + 0, + 4, + assert_final_metric=-export_rate * 1.6 * 24, + assert_final_soc=100 + 2.0 * 24, + battery_soc=100.0, + battery_size=200.0, + with_battery=True, + hybrid=True, + inverter_loss=0.8, + export_limit=100.0, + inverter_limit=2.0, + battery_rate_max_charge=1.0, + battery_rate_max_charge_dc=10.0, + discharge=0, + inverter_can_charge_during_export=True, + assert_clipped=0, + ) failed |= simple_scenario("load_pv", my_predbat, 1, 1, assert_final_metric=0, assert_final_soc=0, with_battery=False) failed |= simple_scenario("pv_only", my_predbat, 0, 1, assert_final_metric=-export_rate * 24, assert_final_soc=0, with_battery=False) failed |= simple_scenario("pv10_only", my_predbat, 0, 1, assert_final_metric=-export_rate * 24, assert_final_soc=0, with_battery=False, pv10=True) @@ -1147,14 +1259,18 @@ def run_model_tests(my_predbat): 0, 2, assert_final_metric=-export_rate * 24 * 0.5, - assert_final_soc=50 + 1.0 * 24 * 0.5, + # 1.5kW AC PV surplus (2kW - 0.5kW export limit). AC-coupled charging stores AC * inverter_loss as DC, + # so absorbing all 1.5kW only needs 1.5 * 0.5 = 0.75kW DC, which is within the 1kW charge rate. The + # battery therefore soaks up all the surplus and nothing is clipped (was previously under-charging at + # 0.5kW DC and clipping the rest due to an AC/DC unit mismatch in the export-limit charge branch). + assert_final_soc=50 + 0.75 * 24, with_battery=True, discharge=0, battery_soc=50, export_limit=0.5, inverter_limit=2.0, inverter_loss=0.5, - assert_clipped=24 * 0.5, + assert_clipped=0, ) failed |= simple_scenario( "battery_discharge_export_limit_ac_pv5",