Skip to content

Commit 63db1ec

Browse files
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.
1 parent d6985b6 commit 63db1ec

7 files changed

Lines changed: 229 additions & 0 deletions

File tree

‎apps/predbat/execute.py‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1297,6 +1297,9 @@ def fetch_inverter_data(self, quick=False):
12971297
self.current_charge_limit = calc_percent_limit(self.current_charge_limit_kwh, self.soc_max)
12981298
self.inverter_support_feedin_first = inverter_support_feedin_first
12991299

1300+
# The device Max SoC caps the planner ceiling; best_soc_max was read fresh in fetch_config_options()
1301+
self.apply_battery_max_soc_ceiling()
1302+
13001303
# Additional PVs without inverters
13011304
pv_power_sensors = self.get_arg("pv_power", [], indirect=False)
13021305
if pv_power_sensors and isinstance(pv_power_sensors, list):

‎apps/predbat/fetch.py‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3288,6 +3288,37 @@ def check_export_more_solar_effective(self):
32883288
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)
32893289
self.export_more_solar_warned_reason = reason
32903290

3291+
def apply_battery_max_soc_ceiling(self):
3292+
"""
3293+
Cap best_soc_max by the per-inverter battery_max_soc settings.
3294+
3295+
Runs after fetch_inverter_data() has refreshed the inverters, because fetch_config_options()
3296+
reads best_soc_max before the inverters of this cycle exist.
3297+
"""
3298+
# Device-side cap on usable energy. Fox exposes its Max SoC setting as battery_max_soc
3299+
# (default 100) per inverter. When any inverter caps below 100%, the planner ceiling is the
3300+
# capacity-weighted usable energy across all inverters, so charge targets and the prediction
3301+
# never model more stored energy than the hardware can reach. soc_max itself (the capacity)
3302+
# is left untouched - it stays the basis of soc_kwh, the reserve and the soc_percent maths.
3303+
if self.inverters:
3304+
device_ceiling = 0.0
3305+
any_capped = False
3306+
for inv in self.inverters:
3307+
inv_max = getattr(inv, "battery_max_soc", 100)
3308+
if inv_max and 0 < inv_max < 100:
3309+
any_capped = True
3310+
else:
3311+
inv_max = 100
3312+
inv_capacity = getattr(inv, "soc_max", None)
3313+
if inv_capacity:
3314+
device_ceiling += inv_capacity * (inv_max / 100.0)
3315+
if any_capped:
3316+
if self.best_soc_max and self.best_soc_max > 0:
3317+
self.best_soc_max = min(self.best_soc_max, device_ceiling)
3318+
else:
3319+
self.best_soc_max = device_ceiling
3320+
self.log("Info: Capped best_soc_max to {:.3f} kWh by the per-inverter Max SoC settings".format(self.best_soc_max))
3321+
32913322
def fetch_config_options(self):
32923323
"""
32933324
Fetch all the configuration options
@@ -3442,6 +3473,7 @@ def fetch_config_options(self):
34423473
self.import_export_scaling = self.get_arg("import_export_scaling", 1.0)
34433474
self.best_soc_min = self.get_arg("best_soc_min")
34443475
self.best_soc_max = self.get_arg("best_soc_max")
3476+
34453477
self.best_soc_keep = self.get_arg("best_soc_keep")
34463478
self.best_soc_keep_weight = self.get_arg("best_soc_keep_weight")
34473479
self.set_soc_minutes = self.plan_interval_minutes

‎apps/predbat/fox.py‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2388,6 +2388,7 @@ async def automatic_config(self):
23882388
self.set_arg("soc_max", [f"sensor.{self.prefix}_fox_{device}_battery_capacity" for device in batteries])
23892389
self.set_arg("reserve", [f"number.{self.prefix}_fox_{device}_battery_schedule_reserve" for device in batteries])
23902390
self.set_arg("battery_min_soc", [f"sensor.{self.prefix}_fox_{device}_battery_reserve_min" for device in batteries])
2391+
self.set_arg("battery_max_soc", [f"number.{self.prefix}_fox_{device}_setting_maxsoc" for device in batteries])
23912392
self.set_arg("charge_start_time", [f"select.{self.prefix}_fox_{device}_battery_schedule_charge_start_time" for device in batteries])
23922393
self.set_arg("charge_end_time", [f"select.{self.prefix}_fox_{device}_battery_schedule_charge_end_time" for device in batteries])
23932394
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
24762477
"soc_max": self._fox_entity(serial, "sensor", "battery_capacity"),
24772478
"reserve": self._fox_entity(serial, "number", "battery_schedule_reserve", "rw"),
24782479
"battery_min_soc": self._fox_entity(serial, "sensor", "battery_reserve_min"),
2480+
"battery_max_soc": self._fox_entity(serial, "number", "setting_maxsoc", "rw"),
24792481
"charge_start_time": self._fox_entity(serial, "select", "battery_schedule_charge_start_time", "rw", **time_select),
24802482
"charge_end_time": self._fox_entity(serial, "select", "battery_schedule_charge_end_time", "rw", **time_select),
24812483
"charge_limit": self._fox_entity(serial, "number", "battery_schedule_charge_soc", "rw"),

‎apps/predbat/inverter.py‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -487,6 +487,7 @@ def refresh_config(self, quiet=False):
487487
self.reserve_percent = self.base.get_arg("battery_min_soc", default=4.0, index=self.id, required_unit="%")
488488
self.reserve_percent_current = self.base.get_arg("battery_min_soc", default=4.0, index=self.id, required_unit="%")
489489
self.battery_scaling = self.base.get_arg("battery_scaling", default=1.0, index=self.id)
490+
self.battery_max_soc = self.base.get_arg("battery_max_soc", default=100, index=self.id, required_unit="%")
490491
if not self.battery_scaling or self.battery_scaling <= 0:
491492
# A parsed value of exactly 0 (or negative) isn't safe to interpret either way - it could
492493
# be a flaky/unavailable API response (e.g. Solis Cloud API returning batteryHealthSoh: 0

‎apps/predbat/tests/test_find_battery_size.py‎

Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1817,6 +1817,157 @@ def run_cycle(soc_max_read):
18171817
return failed
18181818

18191819

1820+
def test_battery_max_soc_ceiling_single_inverter(my_predbat):
1821+
"""
1822+
Test best_soc_max is capped to the device ceiling for a single capped inverter
1823+
1824+
With one inverter at battery_max_soc below 100 the planner ceiling in kWh is the
1825+
inverter's soc_max (capacity) scaled by that percentage. soc_max itself is the
1826+
capacity and must stay unchanged - it remains the basis of soc_kwh, the reserve
1827+
and the soc_percent maths.
1828+
"""
1829+
print("*** Running test: battery_max_soc_ceiling_single_inverter ***")
1830+
failed = False
1831+
1832+
my_predbat.battery_scaling_auto = False
1833+
my_predbat.set_arg("soc_max", 10.0, index=0)
1834+
my_predbat.set_arg("battery_max_soc", 80, index=0)
1835+
my_predbat.set_arg("best_soc_max", 0) # 0 means no user cap, so the ceiling is used
1836+
setup_predbat(my_predbat)
1837+
remove_test_history_data(my_predbat)
1838+
1839+
try:
1840+
inv = Inverter(my_predbat, 0)
1841+
my_predbat.inverters = [inv]
1842+
my_predbat.fetch_config_options()
1843+
my_predbat.apply_battery_max_soc_ceiling()
1844+
expected_ceiling = 10.0 * 80 / 100.0 # 8.0
1845+
if abs(my_predbat.best_soc_max - expected_ceiling) > 0.001:
1846+
print("ERROR: best_soc_max expected {:.3f}, got {:.3f}".format(expected_ceiling, my_predbat.best_soc_max))
1847+
failed = True
1848+
else:
1849+
print("SUCCESS: best_soc_max capped to {:.3f} for a single capped inverter".format(my_predbat.best_soc_max))
1850+
if abs(inv.soc_max - 10.0) > 0.001:
1851+
print("ERROR: soc_max should be unchanged at 10.0, got {:.3f}".format(inv.soc_max))
1852+
failed = True
1853+
else:
1854+
print("SUCCESS: soc_max unchanged at {:.3f}".format(inv.soc_max))
1855+
except Exception as e:
1856+
print("ERROR: test_battery_max_soc_ceiling_single_inverter raised exception: {}".format(e))
1857+
import traceback
1858+
1859+
traceback.print_exc()
1860+
failed = True
1861+
1862+
my_predbat.battery_scaling_auto = False
1863+
return failed
1864+
1865+
1866+
def test_battery_max_soc_ceiling_two_inverters(my_predbat):
1867+
"""
1868+
Test best_soc_max is capped to the summed device ceiling for two inverters
1869+
1870+
With two inverters the planner ceiling is the sum of each inverter's usable
1871+
capacity, where an inverter at battery_max_soc of 100 contributes its full
1872+
soc_max and a capped one contributes soc_max scaled by its percentage.
1873+
"""
1874+
print("*** Running test: battery_max_soc_ceiling_two_inverters ***")
1875+
failed = False
1876+
1877+
my_predbat.battery_scaling_auto = False
1878+
my_predbat.set_arg("soc_max", 10.0, index=0)
1879+
my_predbat.set_arg("battery_max_soc", 80, index=0)
1880+
my_predbat.set_arg("soc_max", 5.0, index=1)
1881+
my_predbat.set_arg("battery_max_soc", 100, index=1)
1882+
my_predbat.set_arg("best_soc_max", 0) # 0 means no user cap, so the ceiling is used
1883+
setup_predbat(my_predbat)
1884+
remove_test_history_data(my_predbat)
1885+
1886+
try:
1887+
inv0 = Inverter(my_predbat, 0)
1888+
inv1 = Inverter(my_predbat, 1)
1889+
my_predbat.inverters = [inv0, inv1]
1890+
my_predbat.fetch_config_options()
1891+
my_predbat.apply_battery_max_soc_ceiling()
1892+
expected_ceiling = 10.0 * 80 / 100.0 + 5.0 * 100 / 100.0 # 8.0 + 5.0 = 13.0
1893+
if abs(my_predbat.best_soc_max - expected_ceiling) > 0.001:
1894+
print("ERROR: best_soc_max expected {:.3f}, got {:.3f}".format(expected_ceiling, my_predbat.best_soc_max))
1895+
failed = True
1896+
else:
1897+
print("SUCCESS: best_soc_max capped to {:.3f} for two inverters".format(my_predbat.best_soc_max))
1898+
if abs(inv0.soc_max - 10.0) > 0.001:
1899+
print("ERROR: inv0 soc_max should be unchanged at 10.0, got {:.3f}".format(inv0.soc_max))
1900+
failed = True
1901+
if abs(inv1.soc_max - 5.0) > 0.001:
1902+
print("ERROR: inv1 soc_max should be unchanged at 5.0, got {:.3f}".format(inv1.soc_max))
1903+
failed = True
1904+
if not failed:
1905+
print("SUCCESS: both inverter soc_max values unchanged")
1906+
except Exception as e:
1907+
print("ERROR: test_battery_max_soc_ceiling_two_inverters raised exception: {}".format(e))
1908+
import traceback
1909+
1910+
traceback.print_exc()
1911+
failed = True
1912+
1913+
my_predbat.battery_scaling_auto = False
1914+
return failed
1915+
1916+
1917+
def test_battery_max_soc_default_100_leaves_best_soc_max(my_predbat):
1918+
"""
1919+
Test the default battery_max_soc of 100 leaves best_soc_max as before
1920+
1921+
With no inverter capping below 100 the planner ceiling is unchanged, so a user
1922+
best_soc_max value must survive fetch_config_options untouched.
1923+
"""
1924+
print("*** Running test: battery_max_soc_default_100_leaves_best_soc_max ***")
1925+
failed = False
1926+
1927+
my_predbat.battery_scaling_auto = False
1928+
my_predbat.set_arg("soc_max", 10.0, index=0)
1929+
my_predbat.set_arg("battery_max_soc", 100, index=0)
1930+
1931+
# Direct get_arg through the ceiling logic so the raw user best_soc_max round-trips,
1932+
# independent of the test harness's HA config_index value for the input_number.
1933+
def mock_get_arg(arg, *a, **k):
1934+
if arg == "best_soc_max":
1935+
return 12.0
1936+
return orig_get_arg(arg, *a, **k)
1937+
1938+
orig_get_arg = my_predbat.get_arg
1939+
my_predbat.get_arg = mock_get_arg
1940+
setup_predbat(my_predbat)
1941+
remove_test_history_data(my_predbat)
1942+
1943+
try:
1944+
inv = Inverter(my_predbat, 0)
1945+
my_predbat.inverters = [inv]
1946+
my_predbat.fetch_config_options()
1947+
my_predbat.apply_battery_max_soc_ceiling()
1948+
if abs(my_predbat.best_soc_max - 12.0) > 0.001:
1949+
print("ERROR: best_soc_max expected unchanged at 12.0, got {:.3f}".format(my_predbat.best_soc_max))
1950+
failed = True
1951+
else:
1952+
print("SUCCESS: best_soc_max unchanged at {:.3f} with default battery_max_soc".format(my_predbat.best_soc_max))
1953+
if abs(inv.soc_max - 10.0) > 0.001:
1954+
print("ERROR: soc_max should be unchanged at 10.0, got {:.3f}".format(inv.soc_max))
1955+
failed = True
1956+
else:
1957+
print("SUCCESS: soc_max unchanged at {:.3f}".format(inv.soc_max))
1958+
except Exception as e:
1959+
print("ERROR: test_battery_max_soc_default_100_leaves_best_soc_max raised exception: {}".format(e))
1960+
import traceback
1961+
1962+
traceback.print_exc()
1963+
failed = True
1964+
finally:
1965+
my_predbat.get_arg = orig_get_arg
1966+
1967+
my_predbat.battery_scaling_auto = False
1968+
return failed
1969+
1970+
18201971
def run_find_battery_size_tests(my_predbat):
18211972
"""
18221973
Run all find_battery_size tests
@@ -1958,5 +2109,17 @@ def run_find_battery_size_tests(my_predbat):
19582109
if failed:
19592110
return failed
19602111

2112+
failed |= test_battery_max_soc_ceiling_single_inverter(my_predbat)
2113+
if failed:
2114+
return failed
2115+
2116+
failed |= test_battery_max_soc_ceiling_two_inverters(my_predbat)
2117+
if failed:
2118+
return failed
2119+
2120+
failed |= test_battery_max_soc_default_100_leaves_best_soc_max(my_predbat)
2121+
if failed:
2122+
return failed
2123+
19612124
print("**** find_battery_size tests completed ****")
19622125
return failed

‎apps/predbat/tests/test_fox_api.py‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6378,6 +6378,29 @@ def test_automatic_config_single_battery(my_predbat):
63786378
return False
63796379

63806380

6381+
def test_automatic_config_binds_fox_maxsoc_to_battery_max_soc(my_predbat):
6382+
"""
6383+
Test automatic_config binds the Fox Max SoC setting entity to battery_max_soc
6384+
6385+
The per-inverter max SoC percent arg must be wired to the number entity that
6386+
carries the device Max SoC setting, mirroring the battery_min_soc floor binding.
6387+
"""
6388+
print(" - test_automatic_config_binds_fox_maxsoc_to_battery_max_soc")
6389+
6390+
fox = MockFoxAPIWithRequests()
6391+
deviceSN = "TEST123456"
6392+
6393+
fox.device_list = [{"deviceSN": deviceSN}]
6394+
fox.device_detail[deviceSN] = {"hasPV": True, "hasBattery": True, "capacity": 8, "function": {"scheduler": True}}
6395+
6396+
captured = capture_automatic_config(fox)
6397+
6398+
sn_lower = deviceSN.lower()
6399+
assert captured.get("battery_max_soc") == [f"number.predbat_fox_{sn_lower}_setting_maxsoc"], captured.get("battery_max_soc")
6400+
6401+
return False
6402+
6403+
63816404
def test_automatic_config_multiple_batteries(my_predbat):
63826405
"""
63836406
Test automatic_config with multiple battery devices
@@ -8231,6 +8254,7 @@ def run_fox_api_tests(my_predbat):
82318254
# automatic_config tests
82328255
failed |= test_automatic_config_single_battery(my_predbat)
82338256
failed |= test_automatic_config_multiple_batteries(my_predbat)
8257+
failed |= test_automatic_config_binds_fox_maxsoc_to_battery_max_soc(my_predbat)
82348258
failed |= test_automatic_config_battery_and_pv_inverter(my_predbat)
82358259
failed |= test_automatic_config_no_scheduler_error(my_predbat)
82368260
failed |= test_automatic_config_custom_prefix(my_predbat)

‎docs/apps-yaml.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1466,6 +1466,10 @@ Be careful of setting the rate at a value higher than your inverter can handle f
14661466
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.
14671467
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.
14681468
- **battery_min_soc** - When set limits the target SoC% setting for charge and discharge to a minimum percentage value
1469+
- **battery_max_soc** - Optional per-inverter upper limit on the usable battery capacity, a percentage (default 100, so unset
1470+
leaves the computed `soc_max` unchanged). When set below 100 the usable capacity Predbat models, plans charge towards and
1471+
predicts against is capped to `soc_max` x `battery_max_soc`/100, mirroring how Fox's device Max SoC setting stops the battery
1472+
filling all the way. The Fox Cloud component binds it to `number.<prefix>_fox_<sn>_setting_maxsoc` automatically.
14691473
- **reserve** - sensor name for the reserve SoC % setting. The reserve SoC is the lower limit target % to discharge the battery down to.
14701474
Can also be set to a fixed percentage rather than an entity name for inverters that have no reserve register to point at -
14711475
the supplied Huawei and Sofar templates do this. A fixed value tells Predbat what the inverter is set to so it can be modelled,

0 commit comments

Comments
 (0)