diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 708e8548e..cae989fe6 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -795,8 +795,10 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi # discharge freeze, reset charge rate by default if set_export_freeze: - # Freeze mode - if (export_window_active) and export_limit_now < 100.0 and (set_export_freeze and (export_limit_now == 99.0 or set_export_freeze_only)): + # Freeze mode - PV surplus beyond load/export still charges the battery on inverters that + # route it there during an export freeze (e.g. FoxESS "Feed-in First"), rather than clipping + # it, so only force the charge rate to zero when the inverter genuinely can't do that (#4207). + if (export_window_active) and export_limit_now < 100.0 and (set_export_freeze and (export_limit_now == 99.0 or set_export_freeze_only)) and not self.inverter_can_charge_during_export: charge_rate_now = battery_rate_min # 0 # Set discharge during charge? @@ -1010,8 +1012,9 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi # Battery draw is only subject to inverter limit for the AC part if inverter_hybrid: charge_rate_now_dc = battery_rate_max_charge_dc - # Freeze mode - if set_export_freeze and export_window_active and export_limit_now < 100.0 and (export_limit_now == 99.0 or set_export_freeze_only): + # Freeze mode - see the equivalent non-hybrid guard above (#4207): only clip PV + # surplus to zero-charge when the inverter can't route it to the battery itself. + if set_export_freeze and export_window_active and export_limit_now < 100.0 and (export_limit_now == 99.0 or set_export_freeze_only) and not self.inverter_can_charge_during_export: charge_rate_now_dc = battery_rate_min # 0 charge_rate_now_curve_dc = ( diff --git a/apps/predbat/prediction_kernel.cpp b/apps/predbat/prediction_kernel.cpp index 571bcce59..757dc233d 100644 --- a/apps/predbat/prediction_kernel.cpp +++ b/apps/predbat/prediction_kernel.cpp @@ -27,7 +27,7 @@ #include #define PK_ABI_VERSION 2 -#define PK_PARITY_REVISION 2 +#define PK_PARITY_REVISION 3 #define PK_MAX_CARS 4 #define PK_RUN_EVERY 5 // const.py RUN_EVERY @@ -579,9 +579,12 @@ int32_t pk_run(int64_t handle, const PkScenario *s, PkResult *out) } } - // Discharge freeze - prediction.py:764-768 + // Discharge freeze - prediction.py:797-802 + // PV surplus beyond load/export still charges the battery on inverters that route it there during + // an export freeze (e.g. FoxESS "Feed-in First"), rather than clipping it, so only force the charge + // rate to zero when the inverter genuinely can't do that (#4207). if (c->set_export_freeze) { - if (export_window_active && export_limit_now < 100.0 && (c->set_export_freeze && (export_limit_now == 99.0 || c->set_export_freeze_only))) { + if (export_window_active && export_limit_now < 100.0 && (c->set_export_freeze && (export_limit_now == 99.0 || c->set_export_freeze_only)) && !c->inverter_can_charge_during_export) { charge_rate_now = battery_rate_min; // 0 } } @@ -747,8 +750,8 @@ int32_t pk_run(int64_t handle, const PkScenario *s, PkResult *out) } else { if (inverter_hybrid) { double charge_rate_now_dc = battery_rate_max_charge_dc; - // Freeze mode - prediction.py:973-975 - if (c->set_export_freeze && export_window_active && export_limit_now < 100.0 && (export_limit_now == 99.0 || c->set_export_freeze_only)) { + // Freeze mode - prediction.py:1013-1017, see the equivalent non-hybrid guard above (#4207) + if (c->set_export_freeze && export_window_active && export_limit_now < 100.0 && (export_limit_now == 99.0 || c->set_export_freeze_only) && !c->inverter_can_charge_during_export) { charge_rate_now_dc = battery_rate_min; // 0 } // Note: Python passes the un-rounded soc for the DC-rate lookup here diff --git a/apps/predbat/prediction_kernel.py b/apps/predbat/prediction_kernel.py index e36fa9d67..5f446f366 100644 --- a/apps/predbat/prediction_kernel.py +++ b/apps/predbat/prediction_kernel.py @@ -31,7 +31,7 @@ # Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp) KERNEL_ABI_VERSION = 2 -KERNEL_PARITY_REVISION = 2 +KERNEL_PARITY_REVISION = 3 # Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp) KERNEL_MAX_CARS = 4 diff --git a/apps/predbat/prediction_kernel_lib_aarch64.so b/apps/predbat/prediction_kernel_lib_aarch64.so index 826b15360..02fbc557d 100755 Binary files a/apps/predbat/prediction_kernel_lib_aarch64.so and b/apps/predbat/prediction_kernel_lib_aarch64.so differ diff --git a/apps/predbat/prediction_kernel_lib_armv7l.so b/apps/predbat/prediction_kernel_lib_armv7l.so index 5210cb796..3dadd85dc 100755 Binary files a/apps/predbat/prediction_kernel_lib_armv7l.so and b/apps/predbat/prediction_kernel_lib_armv7l.so differ diff --git a/apps/predbat/prediction_kernel_lib_darwin_arm64.so b/apps/predbat/prediction_kernel_lib_darwin_arm64.so index c07291a4d..de9ebd678 100755 Binary files a/apps/predbat/prediction_kernel_lib_darwin_arm64.so and b/apps/predbat/prediction_kernel_lib_darwin_arm64.so differ diff --git a/apps/predbat/prediction_kernel_lib_darwin_x86_64.so b/apps/predbat/prediction_kernel_lib_darwin_x86_64.so index 4cba265b4..4eb904913 100755 Binary files a/apps/predbat/prediction_kernel_lib_darwin_x86_64.so and b/apps/predbat/prediction_kernel_lib_darwin_x86_64.so differ diff --git a/apps/predbat/prediction_kernel_lib_i686.so b/apps/predbat/prediction_kernel_lib_i686.so index f5cf4e0ff..d336d29ee 100755 Binary files a/apps/predbat/prediction_kernel_lib_i686.so and b/apps/predbat/prediction_kernel_lib_i686.so differ diff --git a/apps/predbat/prediction_kernel_lib_x86_64.so b/apps/predbat/prediction_kernel_lib_x86_64.so index 6a3278b0a..054c8b057 100755 Binary files a/apps/predbat/prediction_kernel_lib_x86_64.so and b/apps/predbat/prediction_kernel_lib_x86_64.so differ diff --git a/apps/predbat/tests/test_model.py b/apps/predbat/tests/test_model.py index 208cbd0bb..59f4d9997 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -1153,9 +1153,21 @@ def run_model_tests(my_predbat, prediction_kernel=False): 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) - failed |= simple_scenario("battery_discharge_freeze_only", my_predbat, 0, 0.5, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=10, with_battery=True, discharge=0, battery_soc=10, set_export_freeze_only=True) + # These freeze scenarios test an inverter that genuinely can't route PV surplus to the battery during + # an export freeze, hence inverter_can_charge_during_export=False - see battery_discharge_freeze_pv_charge + # below for the default (True) case, where surplus PV charges the battery instead of being clipped (#4207). + 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, inverter_can_charge_during_export=False) + 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, inverter_can_charge_during_export=False + ) + failed |= simple_scenario( + "battery_discharge_freeze_only", my_predbat, 0, 0.5, assert_final_metric=-export_rate * 24 * 0.5, assert_final_soc=10, with_battery=True, discharge=0, battery_soc=10, set_export_freeze_only=True, inverter_can_charge_during_export=False + ) + # #4207: with the default inverter_can_charge_during_export=True, PV surplus beyond load during a freeze + # charges the battery (e.g. FoxESS "Feed-in First") rather than being clipped - SoC should rise by the + # full PV amount over the 24h window instead of staying flat at battery_soc. + failed |= simple_scenario("battery_discharge_freeze_pv_charge", my_predbat, 0, 0.5, assert_final_metric=0, assert_final_soc=10 + 24 * 0.5, with_battery=True, discharge=99, battery_soc=10) + failed |= simple_scenario("battery_discharge_freeze_pv_charge_hybrid", my_predbat, 0, 0.5, assert_final_metric=0, assert_final_soc=10 + 24 * 0.5, with_battery=True, discharge=99, battery_soc=10, hybrid=True) # Force discharge with PV: penalty = discharge_hours * pv_kw * export_rate = 24 * 0.5 * export_rate (full 24h forecast window in these model tests) failed |= simple_scenario(