Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
32 changes: 32 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/fox.py
Original file line number Diff line number Diff line change
Expand Up @@ -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])
Expand Down Expand Up @@ -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"),
Expand Down
1 change: 1 addition & 0 deletions apps/predbat/inverter.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
163 changes: 163 additions & 0 deletions apps/predbat/tests/test_find_battery_size.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
24 changes: 24 additions & 0 deletions apps/predbat/tests/test_fox_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 4 additions & 0 deletions docs/apps-yaml.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.<prefix>_fox_<sn>_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,
Expand Down