diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 0200750ca..649985fec 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -2443,6 +2443,7 @@ "pause_end_time": {"type": "sensor_list", "sensor_type": "none|string", "modify": True, "entries": "num_inverters"}, "inverter_limit": {"type": "sensor_list", "sensor_type": "float", "modify": False, "zero": False, "entries": "num_inverters"}, "inverter_can_charge_during_export": {"type": "boolean"}, + "inverter_freeze_export_discharge_rate": {"type": "float", "zero": True}, "pv_ac_limit": {"type": "float", "zero": True}, "inverter_limit_charge": {"type": "sensor_list", "sensor_type": "integer", "modify": False, "zero": False, "entries": "num_inverters"}, "inverter_limit_charge_dc": {"type": "sensor_list", "sensor_type": "integer", "modify": False, "zero": False, "entries": "num_inverters"}, diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 1b3a0347c..8b57529d9 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -2645,6 +2645,8 @@ def fetch_config_options(self): self.inverter_loss = 1.0 - self.get_arg("inverter_loss") self.inverter_hybrid = self.get_arg("inverter_hybrid") self.pv_ac_limit = self.get_arg("pv_ac_limit", 0.0) / MINUTE_WATT + self.inverter_freeze_export_discharge_rate = max(self.get_arg("inverter_freeze_export_discharge_rate", 0.0), 0.0) / MINUTE_WATT + self.log("Freeze Export discharge rate configured: {:.0f} W".format(self.inverter_freeze_export_discharge_rate * MINUTE_WATT)) self.base_load = self.get_arg("base_load", 100) / 1000.0 # Charge curve diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index 32d2d501d..d48c2c582 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -391,6 +391,7 @@ def reset(self): self.battery_loss = 1.0 self.battery_loss_discharge = 1.0 self.inverter_loss = 1.0 + self.inverter_freeze_export_discharge_rate = 0.0 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..3c318ce90 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,24 @@ 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_draw = min(inverter_freeze_export_discharge_rate * step * battery_loss_discharge, battery_to_min) + 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..917040df1 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; // per-minute rate (multiplied by step in the kernel), 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,19 @@ 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) { + double freeze_draw = std::min(inverter_freeze_export_discharge_rate * step * battery_loss_discharge, battery_to_min); + 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..0c62cc5b7 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..86bf0a1d0 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..0dc456bfe 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..1cf4e64aa 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..a71f111c6 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..814a1b9cc 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..713954267 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -9,7 +9,7 @@ # pylint: disable=attribute-defined-outside-init from datetime import datetime, timedelta -from const import PREDBAT_MAX_CARS +from const import PREDBAT_MAX_CARS, MINUTE_WATT from prediction import Prediction from matplotlib import pyplot as plt import asyncio @@ -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 / MINUTE_WATT 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..662532069 100644 --- a/apps/predbat/tests/test_kernel_parity.py +++ b/apps/predbat/tests/test_kernel_parity.py @@ -33,7 +33,7 @@ import time import prediction_kernel -from const import PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90 +from const import PV_SCENARIO_NOMINAL, PV_SCENARIO_PV10, PV_SCENARIO_PV90, MINUTE_WATT from prediction import Prediction from prediction_kernel import create_kernel_context, run_prediction_kernel, load_kernel from utils import remove_intersecting_windows @@ -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 / MINUTE_WATT) 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) diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 98d151f28..855189dc1 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1196,6 +1196,20 @@ If this setting is `false` then the inverter will not charge the battery and the For Freeze Export specifically, this means the battery still holds its SoC flat while the export limit alone can absorb all the surplus solar - it only starts charging once solar genuinely exceeds what load and the export limit together can use, matching how many hybrid inverters actually behave (e.g. FoxESS's "Feed-in First" mode prioritises house load, then export, then the battery). +### **inverter_freeze_export_discharge_rate** + +Global setting, defaults to `0` (disabled). + +Controls the way Predbat models your inverter, this does not change the way it is controlled. + +Some inverters (observed on AlphaESS) continue a small residual battery discharge during Freeze Export instead of holding the battery perfectly flat. If your inverter behaves this way, set this to the observed battery-side discharge rate in Watts so Predbat's prediction model matches reality. + +```yaml + inverter_freeze_export_discharge_rate: 269 +``` + +When set, Predbat feeds this rate into the normal AC balance during a Freeze Export period: house load consumes it first, and any surplus may reach the grid, subject to the battery reserve and the physical export limit. Leave this at `0` (the default) if your inverter holds the battery flat during Freeze Export. + ## Controlling the Inverter There are a few different ways to control your inverter: