From 3b1e908f3c33df0cca410b1e0bdaa02ff9b38e66 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 21 Jun 2026 20:43:32 +0100 Subject: [PATCH 1/4] Fix for inverter clipping bug --- apps/predbat/prediction.py | 17 +++++++++-- apps/predbat/tests/test_model.py | 52 ++++++++++++++++++++++++++++++-- 2 files changed, 64 insertions(+), 5 deletions(-) diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 6846d56e9..96a1ba61a 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -818,7 +818,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi if reduce_by > battery_draw: 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 +829,21 @@ 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. + battery_draw = max(-reduce_by * inverter_loss_recp, -battery_to_min, -charge_rate_now_curve_dc_step) else: + # 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_min, -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) diff --git a/apps/predbat/tests/test_model.py b/apps/predbat/tests/test_model.py index ee362c001..c62fec614 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -409,6 +409,50 @@ 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, + ) 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 +1191,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", From 9266663b0fb3c766460aa782e9ef113570f97cbd Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 21 Jun 2026 20:55:35 +0100 Subject: [PATCH 2/4] Fix scale-back vs charge pivot to use AC contribution The branch deciding between scaling the battery back and stopping it to charge from surplus PV compared the AC over-export figure against the raw DC battery_draw. With inverter_loss < 1 there is a band (battery_draw * inverter_loss < over_limit <= battery_draw) where the surplus exceeds the battery's actual AC export contribution but not its DC value, so the code took the scale-back path, stopped the battery and clipped the residual PV instead of charging to absorb it. Compare against battery_draw * inverter_loss so the pivot is in consistent AC units. Adds export_pv_charge_band_loss regression test covering the band (verified to fail before this change). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/prediction.py | 5 ++++- apps/predbat/tests/test_model.py | 23 +++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 96a1ba61a..ae5d6675e 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -816,7 +816,10 @@ 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: # 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 diff --git a/apps/predbat/tests/test_model.py b/apps/predbat/tests/test_model.py index c62fec614..a5edf9c57 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -453,6 +453,29 @@ def run_model_tests(my_predbat): 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, + ) 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) From 108736151cdd994ad6280261cdb5a3744798197b Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 21 Jun 2026 20:59:11 +0100 Subject: [PATCH 3/4] Clamp forced-export charging by battery_to_max not battery_to_min The three charge-absorption paths in the forced-export logic (the export limit and inverter limit charge branches) clamped the charge magnitude by -battery_to_min (energy available to discharge down to reserve) instead of -battery_to_max (remaining capacity up to soc_max). Every other charge path in the model uses battery_to_max. With SOC near full this let the model request more charging than the battery can accept; SOC is later clamped to soc_max without adjusting the energy flows, under-reporting clipping and skewing the export accounting. Adds export_pv_charge_full_battery regression test (full battery, high PV forced export) which clips the full surplus once the charge is correctly bounded by zero headroom; verified to fail before this change. Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/prediction.py | 12 ++++++++---- apps/predbat/tests/test_model.py | 20 ++++++++++++++++++++ 2 files changed, 28 insertions(+), 4 deletions(-) diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index ae5d6675e..16fe692ee 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -833,11 +833,13 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi ) charge_rate_now_curve_dc_step = 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. - battery_draw = max(-reduce_by * inverter_loss_recp, -battery_to_min, -charge_rate_now_curve_dc_step) + # 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: # 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_min, -charge_rate_now_curve_step) + battery_draw = max(-reduce_by * inverter_loss, -battery_to_max, -charge_rate_now_curve_step) else: battery_draw = 0 else: @@ -867,7 +869,9 @@ 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) + # Clamp by battery_to_max (remaining charge headroom), not battery_to_min, so a + # near-full battery is not asked to absorb more than it can hold. + battery_draw = max(-reduce_by * inverter_loss, -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 a5edf9c57..400672a18 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -476,6 +476,26 @@ def run_model_tests(my_predbat): 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, + ) 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) From 49c2c4a518bf605e814f1b767a6c9d3f3e8f7d1b Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sun, 21 Jun 2026 21:31:14 +0100 Subject: [PATCH 4/4] Fix inverter-limit charge branch under-charging (over-clipping PV) The hybrid charge branch of the inverter-limit scale-back set the charge to -reduce_by * inverter_loss, but 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). The battery must therefore charge by reduce_by directly to bring total_inverted onto the inverter limit - the inverter_loss factor under-charges and leaves PV to be clipped by the later "Clip solar" stage that the battery could otherwise have absorbed. This is the same AC/DC unit class fixed in the export-limit block; it was the remaining inconsistency in the sibling inverter-limit block (surfaced by code review). The non-hybrid discharge path is unaffected - its under-correction is re-clamped correctly downstream. Adds export_pv_inverter_limit_charge regression test (hybrid, PV above the inverter limit, non-binding export limit) asserting zero clipping; verified to fail before this change (clip 9.6kWh, soc short by 0.4kW*24). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/predbat/prediction.py | 11 ++++++++--- apps/predbat/tests/test_model.py | 25 +++++++++++++++++++++++++ 2 files changed, 33 insertions(+), 3 deletions(-) diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 16fe692ee..da360363c 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -869,9 +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 - # Clamp by battery_to_max (remaining charge headroom), not battery_to_min, so a - # near-full battery is not asked to absorb more than it can hold. - battery_draw = max(-reduce_by * inverter_loss, -battery_to_max, -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 400672a18..509c71783 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -496,6 +496,31 @@ def run_model_tests(my_predbat): 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)