From 8bc874e2e9e6c187507e3dca7758ade87f859b4d Mon Sep 17 00:00:00 2001 From: nickgee31 Date: Thu, 28 May 2026 14:03:21 +0100 Subject: [PATCH] Clamp auto battery_scaling to configured range Preserve manual DoD/usable config when battery_scaling_auto is enabled by introducing battery_scaling_config and clamping auto-scaling to [config*0.8, config]. Update Inverter to set battery_scaling from the computed value and to default battery_scaling to 1.0 when no nominal capacity is configured. Add/adjust unit tests: helper _clamped_auto_scaling, assert battery_scaling is updated in existing auto-scaling tests, add test_battery_scaling_auto_preserves_configured_scaling to ensure measured degradation below configured DoD is applied, and wire the new test into the test runner. --- apps/predbat/inverter.py | 10 ++- apps/predbat/tests/test_find_battery_size.py | 80 +++++++++++++++++++- 2 files changed, 86 insertions(+), 4 deletions(-) diff --git a/apps/predbat/inverter.py b/apps/predbat/inverter.py index acdded6e1..898ba7169 100644 --- a/apps/predbat/inverter.py +++ b/apps/predbat/inverter.py @@ -154,6 +154,7 @@ def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData= 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_scaling_config = self.battery_scaling self.reserve_max = 100 self.battery_rate_max_raw = 2600.0 @@ -585,14 +586,19 @@ def battery_size_tracking(self): if self.base.battery_scaling_auto and trimmed_mean and trimmed_mean > 0: if self.nominal_capacity > 0: - # Clamp scaling to [0.8, 1.0] relative to nominal - new_scaling = max(0.8, min(1.0, trimmed_mean / self.nominal_capacity)) + # Clamp scaling to [80%, 100%] of the configured usable scaling. + # This preserves manual DoD/SOH correction (e.g. 0.8) while allowing measured degradation below it. + scaling_upper = self.battery_scaling_config + scaling_lower = self.battery_scaling_config * 0.8 + new_scaling = max(scaling_lower, min(scaling_upper, trimmed_mean / self.nominal_capacity)) + self.battery_scaling = new_scaling self.soc_max = dp3(self.nominal_capacity * new_scaling) self.log("Info: inverter {} battery_scaling_auto set scaling {:.3f} (mean {:.2f} kWh, nominal {:.2f} kWh) resulting in soc_max {:.3f} kWh".format(self.id, new_scaling, trimmed_mean, self.nominal_capacity, self.soc_max)) else: # No nominal configured - use trimmed mean directly without clamping self.soc_max = dp3(trimmed_mean) self.nominal_capacity = self.soc_max + self.battery_scaling = 1.0 self.base.set_arg("soc_max", self.soc_max, index=self.id) self.base.set_arg("soc_max_nominal", 0.0, index=self.id) self.log("Info: Inverter {} battery_scaling_auto using measured mean {:.2f} kWh (no nominal configured)".format(self.id, trimmed_mean)) diff --git a/apps/predbat/tests/test_find_battery_size.py b/apps/predbat/tests/test_find_battery_size.py index da3ddd9f0..6d4a6680a 100644 --- a/apps/predbat/tests/test_find_battery_size.py +++ b/apps/predbat/tests/test_find_battery_size.py @@ -437,6 +437,13 @@ def _make_inv_for_scaling(my_predbat, nominal_kwh=10.0): return inv +def _clamped_auto_scaling(measured_kwh, nominal_kwh, configured_scaling=1.0): + """ + Return battery_scaling_auto's expected total scaling. + """ + return max(configured_scaling * 0.8, min(configured_scaling, measured_kwh / nominal_kwh)) + + def test_battery_scaling_auto_basic(my_predbat): """ Test that battery_size_tracking with battery_scaling_auto enabled computes a trimmed mean and sets soc_max. @@ -466,10 +473,14 @@ def test_battery_scaling_auto_basic(my_predbat): inv.battery_size_tracking() expected_mean = (9.4 + 9.5) / 2 # trimmed: drop 9.0 and 10.0 # scaling = max(0.8, min(1.0, 9.45/10.0)) = 0.945; soc_max = dp3(10.0 * 0.945) = 9.45 - expected_soc_max = round(nominal * max(0.8, min(1.0, expected_mean / nominal)), 3) + expected_scaling = _clamped_auto_scaling(expected_mean, nominal) + expected_soc_max = round(nominal * expected_scaling, 3) if abs(inv.soc_max - expected_soc_max) > 0.01: print("ERROR: soc_max {} does not match expected {:.3f}".format(inv.soc_max, expected_soc_max)) failed = True + if abs(inv.battery_scaling - expected_scaling) > 0.001: + print("ERROR: battery_scaling {} does not match expected {:.3f}".format(inv.battery_scaling, expected_scaling)) + failed = True # Check today's key was added to the sensor history sensor_state = my_predbat.ha_interface.dummy_items.get(sensor_name, {}) history_attr = sensor_state.get("history", {}) if isinstance(sensor_state, dict) else {} @@ -540,6 +551,9 @@ def test_battery_scaling_auto_clamping(my_predbat): if abs(inv.soc_max - expected_lower) > 0.001: print("ERROR: lower clamp failed, expected {:.3f} got {:.3f}".format(expected_lower, inv.soc_max)) failed = True + elif abs(inv.battery_scaling - 0.8) > 0.001: + print("ERROR: lower clamp battery_scaling failed, expected 0.800 got {:.3f}".format(inv.battery_scaling)) + failed = True else: print("SUCCESS: lower clamp to 0.8 correct") @@ -552,6 +566,9 @@ def test_battery_scaling_auto_clamping(my_predbat): if abs(inv2.soc_max - expected_upper) > 0.001: print("ERROR: upper clamp failed, expected {:.3f} got {:.3f}".format(expected_upper, inv2.soc_max)) failed = True + elif abs(inv2.battery_scaling - 1.0) > 0.001: + print("ERROR: upper clamp battery_scaling failed, expected 1.000 got {:.3f}".format(inv2.battery_scaling)) + failed = True else: print("SUCCESS: upper clamp to 1.0 correct") @@ -559,6 +576,57 @@ def test_battery_scaling_auto_clamping(my_predbat): return failed +def test_battery_scaling_auto_preserves_configured_scaling(my_predbat): + """ + Test that battery_scaling_auto clamps relative to configured battery_scaling. + + An 80% DoD battery has configured battery_scaling=0.8. If historical charge data + measures 7.2 kWh from a 10 kWh nominal battery, the total effective scaling should + become 0.72, not be clamped back up to 0.8 or expanded above the configured DoD. + """ + print("*** Running test: battery_scaling_auto_preserves_configured_scaling ***") + failed = False + nominal = 10.0 + configured_scaling = 0.8 + my_predbat.battery_scaling_auto = True + sensor_name = "sensor.{}_soc_max_calculated".format(my_predbat.prefix) + + inv = _make_inv_for_scaling(my_predbat, nominal) + inv.battery_scaling = configured_scaling + inv.battery_scaling_config = configured_scaling + my_predbat.ha_interface.dummy_items.pop(sensor_name, None) + inv.find_battery_size = lambda _nc=0: 7.2 + inv.battery_size_tracking() + + expected_scaling = 0.72 + expected_soc_max = round(nominal * expected_scaling, 3) + if abs(inv.battery_scaling - expected_scaling) > 0.001: + print("ERROR: expected battery_scaling {:.3f} got {:.3f}".format(expected_scaling, inv.battery_scaling)) + failed = True + elif abs(inv.soc_max - expected_soc_max) > 0.001: + print("ERROR: expected soc_max {:.3f} got {:.3f}".format(expected_soc_max, inv.soc_max)) + failed = True + + inv2 = _make_inv_for_scaling(my_predbat, nominal) + inv2.battery_scaling = configured_scaling + inv2.battery_scaling_config = configured_scaling + my_predbat.ha_interface.dummy_items.pop(sensor_name, None) + inv2.find_battery_size = lambda _nc=0: 8.8 + inv2.battery_size_tracking() + + if abs(inv2.battery_scaling - configured_scaling) > 0.001: + print("ERROR: expected configured upper clamp {:.3f} got {:.3f}".format(configured_scaling, inv2.battery_scaling)) + failed = True + elif abs(inv2.soc_max - nominal * configured_scaling) > 0.001: + print("ERROR: expected upper-clamped soc_max {:.3f} got {:.3f}".format(nominal * configured_scaling, inv2.soc_max)) + failed = True + elif not failed: + print("SUCCESS: auto scaling preserved configured DoD and allowed measured degradation below it") + + my_predbat.battery_scaling_auto = False + return failed + + def test_battery_scaling_auto_skip_today(my_predbat): """ Test that battery_size_tracking does not call find_battery_size when today is already in the sensor history. @@ -590,10 +658,14 @@ def mock_find_should_not_be_called(): print("ERROR: find_battery_size was called {} time(s) but should have been skipped".format(calls[0])) failed = True # stored mean=9.0, nominal=10.0 → scaling=0.9 → soc_max=9.0 - expected_soc_max = round(nominal * max(0.8, min(1.0, 9.0 / nominal)), 3) + expected_scaling = _clamped_auto_scaling(9.0, nominal) + expected_soc_max = round(nominal * expected_scaling, 3) if abs(inv.soc_max - expected_soc_max) > 0.001: print("ERROR: soc_max {} does not match expected {:.3f}".format(inv.soc_max, expected_soc_max)) failed = True + if abs(inv.battery_scaling - expected_scaling) > 0.001: + print("ERROR: battery_scaling {} does not match expected {:.3f}".format(inv.battery_scaling, expected_scaling)) + failed = True if not failed: print("SUCCESS: find_battery_size correctly skipped for today, used stored mean 9.00") except Exception as e: @@ -1166,6 +1238,10 @@ def run_find_battery_size_tests(my_predbat): if failed: return failed + failed |= test_battery_scaling_auto_preserves_configured_scaling(my_predbat) + if failed: + return failed + failed |= test_battery_scaling_auto_skip_today(my_predbat) if failed: return failed