From 046f9c655b16a2d8766777b19166a302399e03c5 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Fri, 24 Apr 2026 21:01:24 +0100 Subject: [PATCH] Clipping model fixes --- apps/predbat/prediction.py | 9 ++++-- apps/predbat/tests/test_infra.py | 8 ++++++ apps/predbat/tests/test_model.py | 49 +++++++++++++++++++++++++++----- 3 files changed, 57 insertions(+), 9 deletions(-) diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 323b19727..1d440d9ed 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -993,8 +993,11 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi total_inverted = get_total_inverted(battery_draw, pv_dc, pv_ac, inverter_loss, inverter_hybrid) if total_inverted > inverter_limit: over_limit = total_inverted - inverter_limit - clipped_today += over_limit + pv_ac_before = pv_ac pv_ac = max(pv_ac - over_limit * inverter_loss, 0) + pv_ac_no_loss = max(pv_ac_before - over_limit, 0) + clipped_today += pv_ac_before - pv_ac_no_loss + total_inverted = get_total_inverted(battery_draw, pv_dc, pv_ac, inverter_loss, inverter_hybrid) else: total_inverted = get_total_inverted(battery_draw, pv_dc, pv_ac, inverter_loss, inverter_hybrid) if total_inverted > inverter_limit: @@ -1008,8 +1011,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp) if diff < 0 and abs(diff) > export_limit: over_limit = abs(diff) - export_limit - clipped_today += over_limit + # Only solar PV is truly "clipped" (lost energy); excess battery discharge just gets limited + pv_ac_before = pv_ac pv_ac = max(pv_ac - over_limit, 0) + clipped_today += pv_ac_before - pv_ac # Adjust battery soc if battery_draw > 0: diff --git a/apps/predbat/tests/test_infra.py b/apps/predbat/tests/test_infra.py index ee43d11f6..968a28cf8 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -591,6 +591,7 @@ def simple_scenario( ignore_failed=False, set_charge_freeze=True, calculate_export_on_pv=True, + assert_clipped=0, ): """ No PV, No Load @@ -818,6 +819,13 @@ def simple_scenario( print("ERROR: iBoost running full should be {}".format(assert_iboost_running_full)) failed = True + if save != "none": + total_clipped = prediction.predict_clipped_best[max(prediction.predict_clipped_best.keys())] if prediction.predict_clipped_best else 0 + if abs(total_clipped - assert_clipped) >= 0.9: + if not ignore_failed: + print("ERROR: Total clipped {} should be {}".format(total_clipped, assert_clipped)) + failed = True + if failed and not ignore_failed: ( metric, diff --git a/apps/predbat/tests/test_model.py b/apps/predbat/tests/test_model.py index a434fd891..016cdc507 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -230,6 +230,7 @@ def run_model_tests(my_predbat): battery_soc=50.0, inverter_loss=0.8, hybrid=True, + assert_clipped=2 * 24, # 1 for the battery on DC and 1 for the PV on AC ) failed |= simple_scenario("load_carbon", my_predbat, 1, 0, assert_final_metric=import_rate * 24, assert_final_soc=0, with_battery=False, carbon=3, assert_final_carbon=3 * 24) failed |= simple_scenario( @@ -487,7 +488,7 @@ def run_model_tests(my_predbat): failed |= simple_scenario("pv_only_bat_ac_clips2b", my_predbat, 0, 2, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, battery_rate_max_charge_dc=2.0) failed |= simple_scenario("pv_only_bat_ac_clips2c", my_predbat, 0, 2, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, battery_rate_max_charge=2.0) failed |= simple_scenario("pv_only_bat_ac_clips3", my_predbat, 0, 3, assert_final_metric=-export_rate * 48, assert_final_soc=24, with_battery=True) - failed |= simple_scenario("pv_only_bat_ac_export_limit", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, export_limit=0.5) + failed |= simple_scenario("pv_only_bat_ac_export_limit", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, export_limit=0.5, assert_clipped=24 * 1.5) failed |= simple_scenario( "pv_only_bat_ac_export_limit_loss", my_predbat, @@ -498,14 +499,15 @@ def run_model_tests(my_predbat): with_battery=True, export_limit=0.1, inverter_loss=0.5, + assert_clipped=24 * 2.9, ) - failed |= simple_scenario("pv_only_bat_ac_export_limit_load", my_predbat, 0.5, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, export_limit=0.5) + failed |= simple_scenario("pv_only_bat_ac_export_limit_load", my_predbat, 0.5, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, export_limit=0.5, assert_clipped=24 * 1) failed |= simple_scenario("pv_only_bat_dc_clips2", my_predbat, 0, 2, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, hybrid=True) failed |= simple_scenario("pv_only_bat_dc_clips2dc", my_predbat, 0, 2, assert_final_metric=0, assert_final_soc=48, with_battery=True, hybrid=True, battery_rate_max_charge_dc=2.0) failed |= simple_scenario("pv_only_bat_dc_clips2dch", my_predbat, 0, 2, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=36, with_battery=True, hybrid=True, battery_rate_max_charge_dc=1.5) failed |= simple_scenario("pv_only_bat_dc_clips2l", my_predbat, 0, 2, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, inverter_loss=0.5) - failed |= simple_scenario("pv_only_bat_dc_clips3", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, hybrid=True) - failed |= simple_scenario("pv_only_bat_dc_clips3l", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, inverter_loss=0.5) + failed |= simple_scenario("pv_only_bat_dc_clips3", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, hybrid=True, assert_clipped=24 * 1) + failed |= simple_scenario("pv_only_bat_dc_clips3l", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, inverter_loss=0.5, assert_clipped=24 * 1) failed |= simple_scenario( "pv_only_bat_dc_clips3l2", my_predbat, @@ -518,7 +520,7 @@ def run_model_tests(my_predbat): inverter_loss=0.5, inverter_limit=2.0, ) - failed |= simple_scenario("pv_only_bat_dc_export_limit", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, export_limit=0.5) + failed |= simple_scenario("pv_only_bat_dc_export_limit", my_predbat, 0, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, export_limit=0.5, assert_clipped=24 * 1.5) failed |= simple_scenario( "pv_only_bat_dc_export_limit_loss", my_predbat, @@ -530,8 +532,32 @@ def run_model_tests(my_predbat): hybrid=True, export_limit=0.1, inverter_loss=0.5, + assert_clipped=24 * 1.9, ) - failed |= simple_scenario("pv_only_bat_dc_export_limit_load", my_predbat, 0.5, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, export_limit=0.5) + + # Export limit less than battery max discharge rate, no solar - battery should just be rate-limited, no clipping + failed_local, prediction = simple_scenario( + "export_limit_no_clip_no_solar", + my_predbat, + 0, + 0, + assert_final_metric=-export_rate * 24 * 0.5, # 0.5 kW * 24h = 12 kWh exported at 5p + assert_final_soc=100 - 12, # 12 kWh drained from 100 kWh battery + with_battery=True, + battery_soc=100.0, + battery_size=100, + battery_rate_max_charge=1.0, # 1 kW max discharge, higher than the export limit + export_limit=0.5, # 0.5 kW export limit - less than max battery discharge rate + discharge=0, # export all the way to empty + return_prediction_handle=True, + ) + failed |= failed_local + total_clipped = max(prediction.predict_clipped_best.values()) if prediction.predict_clipped_best else 0 + if total_clipped > 0: + print("ERROR: export_limit_no_clip_no_solar: clipping should be 0 but got {}".format(total_clipped)) + failed = True + + failed |= simple_scenario("pv_only_bat_dc_export_limit_load", my_predbat, 0.5, 3, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=24, with_battery=True, hybrid=True, export_limit=0.5, assert_clipped=24 * 1) failed |= simple_scenario("battery_charge", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10) failed |= simple_scenario("battery_charge_low_off", my_predbat, 0, 0, assert_final_metric=import_rate * 10, assert_final_soc=10, with_battery=True, charge=10, battery_size=10, set_charge_low_power=False, keep=5, assert_keep=24.59) @@ -675,6 +701,7 @@ def run_model_tests(my_predbat): battery_rate_max_charge_dc=2.0, hybrid=True, export_limit=10.0, + assert_clipped=24 * 1, ) failed |= simple_scenario( @@ -703,6 +730,7 @@ def run_model_tests(my_predbat): inverter_loss=0.5, inverter_limit=2, hybrid=True, + assert_clipped=24 * 1, ) failed |= simple_scenario( "battery_charge_pv_term_dc1", @@ -995,7 +1023,7 @@ def run_model_tests(my_predbat): ) failed |= simple_scenario("battery_discharge_pv2_hybrid", my_predbat, 0, 1.5, assert_final_metric=-export_rate * 24, assert_final_soc=22, with_battery=True, discharge=0, battery_soc=10, hybrid=True) failed |= simple_scenario("battery_discharge_pv3_hybrid", my_predbat, 0, 2, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, discharge=0, battery_soc=0, hybrid=True) - failed |= simple_scenario("battery_discharge_pv3_hybrid2", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, discharge=0, battery_soc=0, hybrid=True) + failed |= simple_scenario("battery_discharge_pv3_hybrid2", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=24, with_battery=True, discharge=0, battery_soc=0, hybrid=True, assert_clipped=24 * 1) failed |= simple_scenario("battery_discharge_pv3_hybrid3", my_predbat, 0, 3, assert_final_metric=-export_rate * 24, assert_final_soc=48, with_battery=True, discharge=0, battery_soc=0, hybrid=True, battery_rate_max_charge_dc=2.0) failed |= simple_scenario( "battery_discharge_pv4_hybrid", @@ -1010,6 +1038,7 @@ def run_model_tests(my_predbat): hybrid=True, inverter_limit=2, inverter_loss=0.5, + assert_clipped=24 * 2, ) failed |= simple_scenario("battery_discharge_freeze", my_predbat, 0, 0.5, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=10, with_battery=True, discharge=99, battery_soc=10) failed |= simple_scenario("battery_discharge_freeze2", my_predbat, 0, 0.5, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=10, with_battery=True, discharge=99, battery_soc=10, set_export_freeze_only=True) @@ -1083,6 +1112,7 @@ def run_model_tests(my_predbat): battery_soc=50, export_limit=0.5, battery_rate_max_charge_dc=10.0, + assert_clipped=24 * 1.5, ) failed |= simple_scenario( "battery_discharge_export_limit_ac_pv2", @@ -1109,6 +1139,7 @@ def run_model_tests(my_predbat): battery_soc=50, export_limit=0.5, inverter_limit=2.0, + assert_clipped=24 * 0.5, ) failed |= simple_scenario( "battery_discharge_export_limit_ac_pv4", @@ -1123,6 +1154,7 @@ def run_model_tests(my_predbat): export_limit=0.5, inverter_limit=2.0, inverter_loss=0.5, + assert_clipped=24 * 0.5, ) failed |= simple_scenario( "battery_discharge_export_limit_ac_pv5", @@ -1152,6 +1184,7 @@ def run_model_tests(my_predbat): inverter_limit=2.0, battery_rate_max_charge=1.0, inverter_can_charge_during_export=False, + assert_clipped=24 * 1.5, ) failed |= simple_scenario( "battery_discharge_export_limit_hybrid", @@ -1219,6 +1252,7 @@ def run_model_tests(my_predbat): battery_soc=50, export_limit=0.5, hybrid=True, + assert_clipped=24 * 0.5, ) failed |= simple_scenario( "battery_discharge_export_limit_hybrid_pv5", @@ -1233,6 +1267,7 @@ def run_model_tests(my_predbat): export_limit=0.5, hybrid=True, battery_rate_max_charge_dc=2.0, + assert_clipped=24 * 0.5, ) failed |= simple_scenario( "battery_charge_ac_loss",