From 63db1ec17199238c85b6a0bfd85308faa1dca0d0 Mon Sep 17 00:00:00 2001 From: theluckystrike <51033404+theluckystrike@users.noreply.github.com> Date: Mon, 12 Oct 2026 13:12:20 +0700 Subject: [PATCH] fix(fox): cap the plan at the inverter Max SoC (#5395) Add a per-inverter battery_max_soc arg (percent, default 100) and bind the Fox Max SoC setting to it. After the inverters refresh, cap best_soc_max at the summed usable energy so charge targets and the prediction stop at the device limit. soc_max stays the capacity. --- apps/predbat/execute.py | 3 + apps/predbat/fetch.py | 32 ++++ apps/predbat/fox.py | 2 + apps/predbat/inverter.py | 1 + apps/predbat/tests/test_find_battery_size.py | 163 +++++++++++++++++++ apps/predbat/tests/test_fox_api.py | 24 +++ docs/apps-yaml.md | 4 + 7 files changed, 229 insertions(+) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 2076b2499..fc58f2958 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -1297,6 +1297,9 @@ def fetch_inverter_data(self, quick=False): self.current_charge_limit = calc_percent_limit(self.current_charge_limit_kwh, self.soc_max) self.inverter_support_feedin_first = inverter_support_feedin_first + # The device Max SoC caps the planner ceiling; best_soc_max was read fresh in fetch_config_options() + self.apply_battery_max_soc_ceiling() + # Additional PVs without inverters pv_power_sensors = self.get_arg("pv_power", [], indirect=False) if pv_power_sensors and isinstance(pv_power_sensors, list): diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 2be91da64..7e162ef7c 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -3288,6 +3288,37 @@ def check_export_more_solar_effective(self): self.log("Warn: export_more_solar is enabled but has no effect, as it works by enabling Freeze Export on idle solar slots and " + reason) self.export_more_solar_warned_reason = reason + def apply_battery_max_soc_ceiling(self): + """ + Cap best_soc_max by the per-inverter battery_max_soc settings. + + Runs after fetch_inverter_data() has refreshed the inverters, because fetch_config_options() + reads best_soc_max before the inverters of this cycle exist. + """ + # Device-side cap on usable energy. Fox exposes its Max SoC setting as battery_max_soc + # (default 100) per inverter. When any inverter caps below 100%, the planner ceiling is the + # capacity-weighted usable energy across all inverters, so charge targets and the prediction + # never model more stored energy than the hardware can reach. soc_max itself (the capacity) + # is left untouched - it stays the basis of soc_kwh, the reserve and the soc_percent maths. + if self.inverters: + device_ceiling = 0.0 + any_capped = False + for inv in self.inverters: + inv_max = getattr(inv, "battery_max_soc", 100) + if inv_max and 0 < inv_max < 100: + any_capped = True + else: + inv_max = 100 + inv_capacity = getattr(inv, "soc_max", None) + if inv_capacity: + device_ceiling += inv_capacity * (inv_max / 100.0) + if any_capped: + if self.best_soc_max and self.best_soc_max > 0: + self.best_soc_max = min(self.best_soc_max, device_ceiling) + else: + self.best_soc_max = device_ceiling + self.log("Info: Capped best_soc_max to {:.3f} kWh by the per-inverter Max SoC settings".format(self.best_soc_max)) + def fetch_config_options(self): """ Fetch all the configuration options @@ -3442,6 +3473,7 @@ def fetch_config_options(self): self.import_export_scaling = self.get_arg("import_export_scaling", 1.0) self.best_soc_min = self.get_arg("best_soc_min") self.best_soc_max = self.get_arg("best_soc_max") + self.best_soc_keep = self.get_arg("best_soc_keep") self.best_soc_keep_weight = self.get_arg("best_soc_keep_weight") self.set_soc_minutes = self.plan_interval_minutes diff --git a/apps/predbat/fox.py b/apps/predbat/fox.py index f0beea303..5c9bb205b 100644 --- a/apps/predbat/fox.py +++ b/apps/predbat/fox.py @@ -2388,6 +2388,7 @@ async def automatic_config(self): self.set_arg("soc_max", [f"sensor.{self.prefix}_fox_{device}_battery_capacity" for device in batteries]) self.set_arg("reserve", [f"number.{self.prefix}_fox_{device}_battery_schedule_reserve" for device in batteries]) self.set_arg("battery_min_soc", [f"sensor.{self.prefix}_fox_{device}_battery_reserve_min" for device in batteries]) + self.set_arg("battery_max_soc", [f"number.{self.prefix}_fox_{device}_setting_maxsoc" for device in batteries]) self.set_arg("charge_start_time", [f"select.{self.prefix}_fox_{device}_battery_schedule_charge_start_time" for device in batteries]) self.set_arg("charge_end_time", [f"select.{self.prefix}_fox_{device}_battery_schedule_charge_end_time" for device in batteries]) self.set_arg("charge_limit", [f"number.{self.prefix}_fox_{device}_battery_schedule_charge_soc" for device in batteries]) @@ -2476,6 +2477,7 @@ def _discovery_entities(self, serial, has_pv, third_party, has_export_limit, fir "soc_max": self._fox_entity(serial, "sensor", "battery_capacity"), "reserve": self._fox_entity(serial, "number", "battery_schedule_reserve", "rw"), "battery_min_soc": self._fox_entity(serial, "sensor", "battery_reserve_min"), + "battery_max_soc": self._fox_entity(serial, "number", "setting_maxsoc", "rw"), "charge_start_time": self._fox_entity(serial, "select", "battery_schedule_charge_start_time", "rw", **time_select), "charge_end_time": self._fox_entity(serial, "select", "battery_schedule_charge_end_time", "rw", **time_select), "charge_limit": self._fox_entity(serial, "number", "battery_schedule_charge_soc", "rw"), diff --git a/apps/predbat/inverter.py b/apps/predbat/inverter.py index 604ab2317..0f6c41fd8 100644 --- a/apps/predbat/inverter.py +++ b/apps/predbat/inverter.py @@ -487,6 +487,7 @@ def refresh_config(self, quiet=False): self.reserve_percent = self.base.get_arg("battery_min_soc", default=4.0, index=self.id, required_unit="%") self.reserve_percent_current = self.base.get_arg("battery_min_soc", default=4.0, index=self.id, required_unit="%") self.battery_scaling = self.base.get_arg("battery_scaling", default=1.0, index=self.id) + self.battery_max_soc = self.base.get_arg("battery_max_soc", default=100, index=self.id, required_unit="%") if not self.battery_scaling or self.battery_scaling <= 0: # A parsed value of exactly 0 (or negative) isn't safe to interpret either way - it could # be a flaky/unavailable API response (e.g. Solis Cloud API returning batteryHealthSoh: 0 diff --git a/apps/predbat/tests/test_find_battery_size.py b/apps/predbat/tests/test_find_battery_size.py index 2fb673d43..3ba5f9042 100644 --- a/apps/predbat/tests/test_find_battery_size.py +++ b/apps/predbat/tests/test_find_battery_size.py @@ -1817,6 +1817,157 @@ def run_cycle(soc_max_read): return failed +def test_battery_max_soc_ceiling_single_inverter(my_predbat): + """ + Test best_soc_max is capped to the device ceiling for a single capped inverter + + With one inverter at battery_max_soc below 100 the planner ceiling in kWh is the + inverter's soc_max (capacity) scaled by that percentage. soc_max itself is the + capacity and must stay unchanged - it remains the basis of soc_kwh, the reserve + and the soc_percent maths. + """ + print("*** Running test: battery_max_soc_ceiling_single_inverter ***") + failed = False + + my_predbat.battery_scaling_auto = False + my_predbat.set_arg("soc_max", 10.0, index=0) + my_predbat.set_arg("battery_max_soc", 80, index=0) + my_predbat.set_arg("best_soc_max", 0) # 0 means no user cap, so the ceiling is used + setup_predbat(my_predbat) + remove_test_history_data(my_predbat) + + try: + inv = Inverter(my_predbat, 0) + my_predbat.inverters = [inv] + my_predbat.fetch_config_options() + my_predbat.apply_battery_max_soc_ceiling() + expected_ceiling = 10.0 * 80 / 100.0 # 8.0 + if abs(my_predbat.best_soc_max - expected_ceiling) > 0.001: + print("ERROR: best_soc_max expected {:.3f}, got {:.3f}".format(expected_ceiling, my_predbat.best_soc_max)) + failed = True + else: + print("SUCCESS: best_soc_max capped to {:.3f} for a single capped inverter".format(my_predbat.best_soc_max)) + if abs(inv.soc_max - 10.0) > 0.001: + print("ERROR: soc_max should be unchanged at 10.0, got {:.3f}".format(inv.soc_max)) + failed = True + else: + print("SUCCESS: soc_max unchanged at {:.3f}".format(inv.soc_max)) + except Exception as e: + print("ERROR: test_battery_max_soc_ceiling_single_inverter raised exception: {}".format(e)) + import traceback + + traceback.print_exc() + failed = True + + my_predbat.battery_scaling_auto = False + return failed + + +def test_battery_max_soc_ceiling_two_inverters(my_predbat): + """ + Test best_soc_max is capped to the summed device ceiling for two inverters + + With two inverters the planner ceiling is the sum of each inverter's usable + capacity, where an inverter at battery_max_soc of 100 contributes its full + soc_max and a capped one contributes soc_max scaled by its percentage. + """ + print("*** Running test: battery_max_soc_ceiling_two_inverters ***") + failed = False + + my_predbat.battery_scaling_auto = False + my_predbat.set_arg("soc_max", 10.0, index=0) + my_predbat.set_arg("battery_max_soc", 80, index=0) + my_predbat.set_arg("soc_max", 5.0, index=1) + my_predbat.set_arg("battery_max_soc", 100, index=1) + my_predbat.set_arg("best_soc_max", 0) # 0 means no user cap, so the ceiling is used + setup_predbat(my_predbat) + remove_test_history_data(my_predbat) + + try: + inv0 = Inverter(my_predbat, 0) + inv1 = Inverter(my_predbat, 1) + my_predbat.inverters = [inv0, inv1] + my_predbat.fetch_config_options() + my_predbat.apply_battery_max_soc_ceiling() + expected_ceiling = 10.0 * 80 / 100.0 + 5.0 * 100 / 100.0 # 8.0 + 5.0 = 13.0 + if abs(my_predbat.best_soc_max - expected_ceiling) > 0.001: + print("ERROR: best_soc_max expected {:.3f}, got {:.3f}".format(expected_ceiling, my_predbat.best_soc_max)) + failed = True + else: + print("SUCCESS: best_soc_max capped to {:.3f} for two inverters".format(my_predbat.best_soc_max)) + if abs(inv0.soc_max - 10.0) > 0.001: + print("ERROR: inv0 soc_max should be unchanged at 10.0, got {:.3f}".format(inv0.soc_max)) + failed = True + if abs(inv1.soc_max - 5.0) > 0.001: + print("ERROR: inv1 soc_max should be unchanged at 5.0, got {:.3f}".format(inv1.soc_max)) + failed = True + if not failed: + print("SUCCESS: both inverter soc_max values unchanged") + except Exception as e: + print("ERROR: test_battery_max_soc_ceiling_two_inverters raised exception: {}".format(e)) + import traceback + + traceback.print_exc() + failed = True + + my_predbat.battery_scaling_auto = False + return failed + + +def test_battery_max_soc_default_100_leaves_best_soc_max(my_predbat): + """ + Test the default battery_max_soc of 100 leaves best_soc_max as before + + With no inverter capping below 100 the planner ceiling is unchanged, so a user + best_soc_max value must survive fetch_config_options untouched. + """ + print("*** Running test: battery_max_soc_default_100_leaves_best_soc_max ***") + failed = False + + my_predbat.battery_scaling_auto = False + my_predbat.set_arg("soc_max", 10.0, index=0) + my_predbat.set_arg("battery_max_soc", 100, index=0) + + # Direct get_arg through the ceiling logic so the raw user best_soc_max round-trips, + # independent of the test harness's HA config_index value for the input_number. + def mock_get_arg(arg, *a, **k): + if arg == "best_soc_max": + return 12.0 + return orig_get_arg(arg, *a, **k) + + orig_get_arg = my_predbat.get_arg + my_predbat.get_arg = mock_get_arg + setup_predbat(my_predbat) + remove_test_history_data(my_predbat) + + try: + inv = Inverter(my_predbat, 0) + my_predbat.inverters = [inv] + my_predbat.fetch_config_options() + my_predbat.apply_battery_max_soc_ceiling() + if abs(my_predbat.best_soc_max - 12.0) > 0.001: + print("ERROR: best_soc_max expected unchanged at 12.0, got {:.3f}".format(my_predbat.best_soc_max)) + failed = True + else: + print("SUCCESS: best_soc_max unchanged at {:.3f} with default battery_max_soc".format(my_predbat.best_soc_max)) + if abs(inv.soc_max - 10.0) > 0.001: + print("ERROR: soc_max should be unchanged at 10.0, got {:.3f}".format(inv.soc_max)) + failed = True + else: + print("SUCCESS: soc_max unchanged at {:.3f}".format(inv.soc_max)) + except Exception as e: + print("ERROR: test_battery_max_soc_default_100_leaves_best_soc_max raised exception: {}".format(e)) + import traceback + + traceback.print_exc() + failed = True + finally: + my_predbat.get_arg = orig_get_arg + + my_predbat.battery_scaling_auto = False + return failed + + def run_find_battery_size_tests(my_predbat): """ Run all find_battery_size tests @@ -1958,5 +2109,17 @@ def run_find_battery_size_tests(my_predbat): if failed: return failed + failed |= test_battery_max_soc_ceiling_single_inverter(my_predbat) + if failed: + return failed + + failed |= test_battery_max_soc_ceiling_two_inverters(my_predbat) + if failed: + return failed + + failed |= test_battery_max_soc_default_100_leaves_best_soc_max(my_predbat) + if failed: + return failed + print("**** find_battery_size tests completed ****") return failed diff --git a/apps/predbat/tests/test_fox_api.py b/apps/predbat/tests/test_fox_api.py index 40907246b..29958912d 100644 --- a/apps/predbat/tests/test_fox_api.py +++ b/apps/predbat/tests/test_fox_api.py @@ -6378,6 +6378,29 @@ def test_automatic_config_single_battery(my_predbat): return False +def test_automatic_config_binds_fox_maxsoc_to_battery_max_soc(my_predbat): + """ + Test automatic_config binds the Fox Max SoC setting entity to battery_max_soc + + The per-inverter max SoC percent arg must be wired to the number entity that + carries the device Max SoC setting, mirroring the battery_min_soc floor binding. + """ + print(" - test_automatic_config_binds_fox_maxsoc_to_battery_max_soc") + + fox = MockFoxAPIWithRequests() + deviceSN = "TEST123456" + + fox.device_list = [{"deviceSN": deviceSN}] + fox.device_detail[deviceSN] = {"hasPV": True, "hasBattery": True, "capacity": 8, "function": {"scheduler": True}} + + captured = capture_automatic_config(fox) + + sn_lower = deviceSN.lower() + assert captured.get("battery_max_soc") == [f"number.predbat_fox_{sn_lower}_setting_maxsoc"], captured.get("battery_max_soc") + + return False + + def test_automatic_config_multiple_batteries(my_predbat): """ Test automatic_config with multiple battery devices @@ -8231,6 +8254,7 @@ def run_fox_api_tests(my_predbat): # automatic_config tests failed |= test_automatic_config_single_battery(my_predbat) failed |= test_automatic_config_multiple_batteries(my_predbat) + failed |= test_automatic_config_binds_fox_maxsoc_to_battery_max_soc(my_predbat) failed |= test_automatic_config_battery_and_pv_inverter(my_predbat) failed |= test_automatic_config_no_scheduler_error(my_predbat) failed |= test_automatic_config_custom_prefix(my_predbat) diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index c87acc0cf..198ad167b 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1466,6 +1466,10 @@ Be careful of setting the rate at a value higher than your inverter can handle f If not set or set to 0, Predbat will attempt to automatically determine the battery size by analyzing historical charging data from `soc_percent` and `battery_power` sensors. This requires at least several days of historical data with charging periods of 15% or more SoC change. If automatic detection fails, you must manually set this value. - **battery_min_soc** - When set limits the target SoC% setting for charge and discharge to a minimum percentage value +- **battery_max_soc** - Optional per-inverter upper limit on the usable battery capacity, a percentage (default 100, so unset +leaves the computed `soc_max` unchanged). When set below 100 the usable capacity Predbat models, plans charge towards and +predicts against is capped to `soc_max` x `battery_max_soc`/100, mirroring how Fox's device Max SoC setting stops the battery +filling all the way. The Fox Cloud component binds it to `number._fox__setting_maxsoc` automatically. - **reserve** - sensor name for the reserve SoC % setting. The reserve SoC is the lower limit target % to discharge the battery down to. Can also be set to a fixed percentage rather than an entity name for inverters that have no reserve register to point at - the supplied Huawei and Sofar templates do this. A fixed value tells Predbat what the inverter is set to so it can be modelled,