diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 21538d31e..3bd810b49 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -388,6 +388,9 @@ def reset(self): self.battery_loss = 1.0 self.battery_loss_discharge = 1.0 self.inverter_loss = 1.0 + # Battery-side discharge (W) that may continue to supply house load during Freeze Export. + self.inverter_freeze_export_discharge_rate = max(float(self.args.get("inverter_freeze_export_discharge_rate", 0)), 0.0) + self.log("Freeze Export discharge rate configured: {:.0f} W".format(self.inverter_freeze_export_discharge_rate)) self.inverter_hybrid = True self.pv_ac_limit = 0 self.inverter_soc_reset = False diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index 8bc7df763..6839c16ba 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -128,6 +128,7 @@ def __init__( self.iboost_rate_threshold_export = base.iboost_rate_threshold_export self.rate_gas = base.rate_gas self.inverter_loss = base.inverter_loss + self.inverter_freeze_export_discharge_rate = base.inverter_freeze_export_discharge_rate self.inverter_hybrid = base.inverter_hybrid self.inverter_limit = base.inverter_limit self.export_limit = base.export_limit @@ -634,6 +635,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi battery_rate_max_discharge = self.battery_rate_max_discharge battery_rate_max_export = self.battery_rate_max_export battery_rate_min = self.battery_rate_min + inverter_freeze_export_discharge_rate = self.inverter_freeze_export_discharge_rate carbon_intensity = self.carbon_intensity set_discharge_during_charge = self.set_discharge_during_charge battery_charge_power_curve_tuple = charge_curve_to_tuple(self.battery_charge_power_curve) @@ -1089,7 +1091,25 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi pv_dc = min(abs(battery_draw), pv_now) pv_ac = (pv_now - pv_dc) * inverter_loss_ac - battery_state = "fz+" if battery_draw < 0 else "fz~" + # Some inverters (observed on AlphaESS) continue a small residual battery + # discharge during Freeze Export. Treat the configured value as battery-side + # power and feed it into the normal AC balance: house load consumes it first and + # any surplus may reach the grid. Respect the battery reserve and physical export + # limit rather than capping the discharge at house demand. + if inverter_freeze_export_discharge_rate > 0 and battery_draw >= 0: + freeze_soc_available = min(inverter_freeze_export_discharge_rate * step / 60000.0, max(soc - reserve_expected, 0)) + freeze_draw = freeze_soc_available * battery_loss_discharge + freeze_diff = get_diff(freeze_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp) + if freeze_diff < 0 and abs(freeze_diff) > export_limit: + freeze_draw = max(freeze_draw - (abs(freeze_diff) - export_limit) * inverter_loss_recp, 0) + battery_draw = freeze_draw + + if battery_draw < 0: + battery_state = "fz+" + elif battery_draw > 0: + battery_state = "fz-" + else: + battery_state = "fz~" else: # ECO Mode pv_ac = pv_now * inverter_loss_ac diff --git a/apps/predbat/prediction_kernel.cpp b/apps/predbat/prediction_kernel.cpp index 8f4d96bbd..4c6a66ad4 100644 --- a/apps/predbat/prediction_kernel.cpp +++ b/apps/predbat/prediction_kernel.cpp @@ -42,8 +42,8 @@ // unconditionally, so loading one against this Python segfaults on the first prediction rather than // falling back. Bumping makes the loader reject it and use the Python engine, which is the whole // point of the check. -#define PK_ABI_VERSION 4 -#define PK_PARITY_REVISION 7 +#define PK_ABI_VERSION 5 +#define PK_PARITY_REVISION 9 #define PK_MAX_CARS 8 #define PK_RUN_EVERY 5 // const.py RUN_EVERY @@ -169,6 +169,7 @@ struct PkContext { double battery_loss; double battery_loss_discharge; double inverter_loss; + double inverter_freeze_export_discharge_rate; // W, residual battery-side discharge entering the AC balance during Freeze Export double inverter_limit; // per-minute rate (multiplied by step in the kernel) double export_limit; // per-minute rate double pv_ac_limit; // per-minute rate @@ -714,6 +715,7 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu const double battery_rate_max_discharge = c->battery_rate_max_discharge; const double battery_rate_max_export = c->battery_rate_max_export; const double battery_rate_min = c->battery_rate_min; + const double inverter_freeze_export_discharge_rate = c->inverter_freeze_export_discharge_rate; // PV10 de-rating of the charge rate - prediction.py:587-592. PV90 is the upside case, no de-rate. const double battery_rate_max_scaling = is_pv10 ? c->battery_rate_max_scaling10 : c->battery_rate_max_scaling; const double battery_rate_max_scaling_discharge = c->battery_rate_max_scaling_discharge; @@ -1077,6 +1079,20 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu pv_ac = (pv_now - pv_dc) * inverter_loss_ac; } } + + // Some inverters (observed on AlphaESS) continue a small residual battery + // discharge during Freeze Export. Feed the battery-side rate into the normal AC + // balance so load consumes it first and any surplus may export, while respecting + // the reserve and the physical grid export limit. + if (inverter_freeze_export_discharge_rate > 0 && battery_draw >= 0) { + const double freeze_soc_available = std::min(inverter_freeze_export_discharge_rate * step / 60000.0, std::max(soc - reserve_expected, 0.0)); + double freeze_draw = freeze_soc_available * battery_loss_discharge; + const double freeze_diff = get_diff(freeze_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp); + if (freeze_diff < 0 && std::abs(freeze_diff) > export_limit) { + freeze_draw = std::max(freeze_draw - (std::abs(freeze_diff) - export_limit) * inverter_loss_recp, 0.0); + } + battery_draw = freeze_draw; + } } else { // ECO Mode - prediction.py:951-997 pv_ac = pv_now * inverter_loss_ac; diff --git a/apps/predbat/prediction_kernel.py b/apps/predbat/prediction_kernel.py index 69b5f66d8..e2110ae15 100644 --- a/apps/predbat/prediction_kernel.py +++ b/apps/predbat/prediction_kernel.py @@ -31,8 +31,8 @@ from utils import get_curve_value, find_battery_temperature_cap, in_car_slot, in_iboost_slot # Expected ABI/parity revisions of the shared library (see prediction_kernel.cpp) -KERNEL_ABI_VERSION = 4 -KERNEL_PARITY_REVISION = 7 +KERNEL_ABI_VERSION = 5 +KERNEL_PARITY_REVISION = 9 # Maximum number of cars supported by the kernel (PK_MAX_CARS in prediction_kernel.cpp) KERNEL_MAX_CARS = PREDBAT_MAX_CARS @@ -78,6 +78,7 @@ class PkContext(ctypes.Structure): ("battery_loss", ctypes.c_double), ("battery_loss_discharge", ctypes.c_double), ("inverter_loss", ctypes.c_double), + ("inverter_freeze_export_discharge_rate", ctypes.c_double), ("inverter_limit", ctypes.c_double), ("export_limit", ctypes.c_double), ("pv_ac_limit", ctypes.c_double), @@ -658,6 +659,7 @@ def create_kernel_context(pred, static_cache=None): ctx.battery_loss = pred.battery_loss ctx.battery_loss_discharge = pred.battery_loss_discharge ctx.inverter_loss = pred.inverter_loss + ctx.inverter_freeze_export_discharge_rate = pred.inverter_freeze_export_discharge_rate ctx.inverter_limit = pred.inverter_limit ctx.export_limit = pred.export_limit ctx.pv_ac_limit = pred.pv_ac_limit diff --git a/apps/predbat/prediction_kernel_lib_aarch64.so b/apps/predbat/prediction_kernel_lib_aarch64.so index e95240b41..8bf52eeb7 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 7584135fa..4bfea1486 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 8e3f4e56b..0794a6f2b 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 d2137935b..dfe6dfa98 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 47f6a19ad..b60382f7d 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 2e8a7600a..cfe4dbcb4 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_infra.py b/apps/predbat/tests/test_infra.py index 0e476378c..eff76fc75 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -590,6 +590,7 @@ def simple_scenario( charge_window_best=[], charge_limit_best=None, inverter_loss=1.0, + inverter_freeze_export_discharge_rate=0.0, battery_rate_max_charge=1.0, battery_rate_max_charge_dc=None, charge_car=0, @@ -612,6 +613,7 @@ def simple_scenario( keep=0.0, keep_weight=0.5, assert_keep=0.0, + assert_battery_cycle=None, save="best", quiet=False, iboost_rate_threshold=9999, @@ -700,6 +702,7 @@ def simple_scenario( my_predbat.pv_ac_limit = pv_ac_limit / 60.0 my_predbat.reserve = reserve my_predbat.inverter_loss = inverter_loss + my_predbat.inverter_freeze_export_discharge_rate = inverter_freeze_export_discharge_rate my_predbat.battery_rate_max_charge = battery_rate_max_charge / 60.0 my_predbat.battery_rate_max_charge_dc = battery_rate_max_charge_dc / 60.0 my_predbat.battery_rate_max_discharge = battery_rate_max_charge / 60.0 @@ -856,6 +859,10 @@ def simple_scenario( if not ignore_failed: print("ERROR: Final SOC {} should be {}".format(final_soc, assert_final_soc)) failed = True + if assert_battery_cycle is not None and abs(battery_cycle - assert_battery_cycle) >= 0.001: + if not ignore_failed: + print("ERROR: Battery cycle {} should be {}".format(battery_cycle, assert_battery_cycle)) + failed = True if abs(final_iboost - assert_final_iboost) >= 0.1: if not ignore_failed: print("ERROR: Final iBoost {} should be {}".format(final_iboost, assert_final_iboost)) diff --git a/apps/predbat/tests/test_kernel_parity.py b/apps/predbat/tests/test_kernel_parity.py index bdcd49025..ad3f16f8f 100644 --- a/apps/predbat/tests/test_kernel_parity.py +++ b/apps/predbat/tests/test_kernel_parity.py @@ -80,6 +80,7 @@ "battery_loss_discharge", "inverter_hybrid", "inverter_loss", + "inverter_freeze_export_discharge_rate", "inverter_limit", "export_limit", "pv_ac_limit", @@ -219,6 +220,8 @@ def apply_random_scenario(my_predbat, rng): my_predbat.set_reserve_enable = rng.choice([True, False]) my_predbat.set_export_freeze = rng.choice([True, False]) my_predbat.set_export_freeze_only = rng.choice([True, False, False, False]) + # Exercise a non-zero value without consuming another RNG draw, preserving seeded scenarios. + my_predbat.inverter_freeze_export_discharge_rate = 240.0 if my_predbat.set_export_freeze else 0.0 my_predbat.set_charge_window = rng.choice([True, True, False]) my_predbat.set_export_window = rng.choice([True, True, False]) my_predbat.set_discharge_during_charge = rng.choice([True, False]) diff --git a/apps/predbat/tests/test_model.py b/apps/predbat/tests/test_model.py index 208cbd0bb..b25e6c4c3 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -20,6 +20,121 @@ def run_model_tests(my_predbat, prediction_kernel=False): reset_rates(my_predbat, import_rate, export_rate) failed = False + # Freeze Export residual discharge is real battery energy entering the AC balance. + # House load consumes it first; any excess is exported. Normal battery discharge is + # disabled here so only this configured path is under test. + failed |= simple_scenario( + "freeze_export_ac_flow_default_zero", + my_predbat, + 1.0, + 0, + assert_final_metric=10.0, + assert_final_soc=10.0, + battery_size=10.0, + battery_soc=10.0, + discharge=99, + end_record=60, + inverter_freeze_export_discharge_rate=0.0, + battery_rate_max_charge=0.0, + assert_battery_cycle=0.0, + ) + failed |= simple_scenario( + "freeze_export_ac_flow_240w_one_hour", + my_predbat, + 1.0, + 0, + assert_final_metric=7.6, + assert_final_soc=9.76, + battery_size=10.0, + battery_soc=10.0, + discharge=99, + end_record=60, + inverter_freeze_export_discharge_rate=240.0, + battery_rate_max_charge=0.0, + assert_battery_cycle=0.24, + ) + failed |= simple_scenario( + "freeze_export_ac_flow_respects_inverter_loss", + my_predbat, + 1.0, + 0, + assert_final_metric=8.08, + assert_final_soc=9.76, + battery_size=10.0, + battery_soc=10.0, + discharge=99, + end_record=60, + inverter_loss=0.8, + inverter_freeze_export_discharge_rate=240.0, + battery_rate_max_charge=0.0, + assert_battery_cycle=0.24, + ) + failed |= simple_scenario( + "freeze_export_ac_flow_no_load_exports_residual", + my_predbat, + 0, + 0, + assert_final_metric=-1.2, + assert_final_soc=9.76, + battery_size=10.0, + battery_soc=10.0, + discharge=99, + end_record=60, + inverter_freeze_export_discharge_rate=240.0, + battery_rate_max_charge=0.0, + assert_battery_cycle=0.24, + ) + # Live AlphaESS behaviour: PV nearly covers the house, but Freeze Export residual + # discharge continues and the surplus reaches grid (487 W load, 466 W PV, 269 W battery). + failed |= simple_scenario( + "freeze_export_ac_flow_surplus_reaches_grid", + my_predbat, + 0.487, + 0.466, + assert_final_metric=-1.24, + assert_final_soc=9.731, + battery_size=10.0, + battery_soc=10.0, + discharge=99, + end_record=60, + inverter_freeze_export_discharge_rate=269.0, + battery_rate_max_charge=0.0, + assert_battery_cycle=0.269, + ) + failed |= simple_scenario( + "freeze_export_ac_flow_not_outside_freeze", + my_predbat, + 1.0, + 0, + assert_final_metric=10.0, + assert_final_soc=10.0, + battery_size=10.0, + battery_soc=10.0, + discharge=100, + end_record=60, + inverter_freeze_export_discharge_rate=240.0, + battery_rate_max_charge=0.0, + assert_battery_cycle=0.0, + ) + failed |= simple_scenario( + "freeze_export_ac_flow_reserve_floor", + my_predbat, + 1.0, + 0, + assert_final_metric=9.0, + assert_final_soc=4.0, + battery_size=10.0, + battery_soc=4.1, + reserve=4.0, + discharge=99, + end_record=60, + inverter_freeze_export_discharge_rate=240.0, + battery_rate_max_charge=0.0, + assert_battery_cycle=0.1, + ) + if failed: + return failed + failed |= simple_scenario("zero", my_predbat, 0, 0, 0, 0, with_battery=False) failed |= simple_scenario("load_only", my_predbat, 1, 0, assert_final_metric=import_rate * 24, assert_final_soc=0, with_battery=False) failed |= simple_scenario("load_bat_ac", my_predbat, 4, 0, assert_final_metric=import_rate * 24 * 3.2, assert_final_soc=100 - 24, with_battery=True, battery_soc=100.0, inverter_loss=0.8)