diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index ac0fa1f0a..d96e72db4 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -388,6 +388,10 @@ def reset(self): self.battery_loss = 1.0 self.battery_loss_discharge = 1.0 self.inverter_loss = 1.0 + # Internal battery discharge rate (W) observed while Freeze Export is active. + # Prediction-only: this does not alter inverter control or house/grid energy. + 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 432a52b0d..fc863798b 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) @@ -713,6 +715,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi charge_window_active = charge_window_n >= 0 export_window_active = export_window_n >= 0 export_limit_now = export_limits[export_window_n] if export_window_active else 100.0 + freeze_export_active = set_export_freeze and export_window_active and export_limit_now < 100.0 and (export_limit_now == 99.0 or set_export_freeze_only) # Find charge limit charge_limit_n = 0 @@ -1204,6 +1207,14 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi else: soc = min(soc - battery_draw * battery_loss, soc_max) + # Some inverters continue to discharge the battery internally while Freeze Export + # blocks normal external battery flow. Model that measured battery-side discharge + # directly in SoC without creating fictitious house load or grid energy. + freeze_export_discharge_step = 0.0 + if freeze_export_active and inverter_freeze_export_discharge_rate > 0: + freeze_export_discharge_step = min(inverter_freeze_export_discharge_rate * step / 60000.0, max(soc - reserve_expected, 0)) + soc -= freeze_export_discharge_step + # Iboost finally count if self.iboost_enable: # iBoost Solar diversion model @@ -1232,8 +1243,8 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi if iboost_next > self.iboost_today: iboost_running = True - # Count battery cycles - battery_cycle = battery_cycle + abs(battery_draw) + # Count battery cycles, including internal Freeze Export battery discharge + battery_cycle = battery_cycle + abs(battery_draw) + freeze_export_discharge_step # Work out left over energy after battery adjustment diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp) diff --git a/apps/predbat/prediction_kernel.cpp b/apps/predbat/prediction_kernel.cpp index 3fa33226f..92c521afc 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 8 #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, internal battery discharge while Freeze Export is active 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; @@ -752,6 +754,7 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu const bool charge_window_active = charge_window_n >= 0; const bool export_window_active = export_window_n >= 0; const double export_limit_now = export_window_active ? s->export_limits[export_window_n] : 100.0; + const bool freeze_export_active = c->set_export_freeze && export_window_active && export_limit_now < 100.0 && (export_limit_now == 99.0 || c->set_export_freeze_only); // Find charge limit - prediction.py:609-620 double charge_limit_n = 0; @@ -1188,6 +1191,14 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu soc = std::min(soc - battery_draw * battery_loss, soc_max); } + // Internal, directional battery discharge while Freeze Export is active. + // This mirrors prediction.py and deliberately does not alter battery_draw/grid energy. + double freeze_export_discharge_step = 0.0; + if (freeze_export_active && inverter_freeze_export_discharge_rate > 0) { + freeze_export_discharge_step = std::min(inverter_freeze_export_discharge_rate * step / 60000.0, std::max(soc - reserve_expected, 0.0)); + soc -= freeze_export_discharge_step; + } + // iBoost final count - prediction.py:1066-1092 if (c->iboost_enable) { // iBoost solar excess diversion model - prediction.py:1068-1078 (uses the pre-clip diff) @@ -1225,7 +1236,7 @@ static int32_t pk_run_one(const ContextStore *store, const PkScenario *s, PkResu } // Count battery cycles - prediction.py:1094-1095 - battery_cycle = battery_cycle + std::fabs(battery_draw); + battery_cycle = battery_cycle + std::fabs(battery_draw) + freeze_export_discharge_step; // Work out left over energy after battery adjustment - prediction.py:1097-1098 diff = get_diff(battery_draw, pv_dc, pv_ac, load_yesterday, inverter_loss, inverter_loss_recp); diff --git a/apps/predbat/prediction_kernel.py b/apps/predbat/prediction_kernel.py index 69b5f66d8..133c9e554 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 = 8 # 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..e385fb8ee 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..1a1a7d93d 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..6add6c536 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..e4ee0eef0 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..553d55220 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..a6410d5f7 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 76f592b44..0403715d0 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -586,6 +586,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, @@ -608,6 +609,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, @@ -696,6 +698,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 @@ -852,6 +855,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..70e406375 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -20,6 +20,68 @@ def run_model_tests(my_predbat, prediction_kernel=False): reset_rates(my_predbat, import_rate, export_rate) failed = False + # Freeze Export internal battery discharge regression coverage. 240 W for one hour + # is 0.24 kWh. It applies only in Freeze Export, respects reserve, and counts as cycling. + failed |= simple_scenario( + "freeze_export_discharge_rate_default_zero", + my_predbat, + 0, + 0, + assert_final_metric=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, + assert_battery_cycle=0.0, + ) + failed |= simple_scenario( + "freeze_export_discharge_rate_240w_one_hour", + my_predbat, + 0, + 0, + assert_final_metric=0, + 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, + assert_battery_cycle=0.24, + ) + failed |= simple_scenario( + "freeze_export_discharge_rate_not_outside_freeze", + my_predbat, + 0, + 0, + assert_final_metric=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, + assert_battery_cycle=0.0, + ) + failed |= simple_scenario( + "freeze_export_discharge_rate_reserve_floor", + my_predbat, + 0, + 0, + assert_final_metric=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, + 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)