diff --git a/apps/predbat/inverter.py b/apps/predbat/inverter.py index 7f2072069..acdded6e1 100644 --- a/apps/predbat/inverter.py +++ b/apps/predbat/inverter.py @@ -372,8 +372,8 @@ def __init__(self, base, id=0, quiet=False, rest_postCommand=None, rest_getData= ivtime = idetails["Invertor_Time"] else: self.battery_temperature = self.base.get_arg("battery_temperature", default=20, index=self.id, required_unit="°C") - self.soc_max = self.base.get_arg("soc_max", default=0.0, index=self.id) * self.battery_scaling - self.nominal_capacity = self.soc_max + self.nominal_capacity = self.base.get_arg("soc_max", default=0.0, index=self.id) + self.soc_max = self.nominal_capacity * self.battery_scaling if self.inverter_type in ["GE", "GEC", "GEE"]: self.battery_rate_max_raw = self.base.get_arg("charge_rate", attribute="max", index=self.id, default=2600.0, required_unit="W") @@ -641,6 +641,7 @@ def update_soc_max_calculated_sensor(self, found_size, nominal_capacity=0): "history": history, "nominal_capacity": round(nominal_capacity, 3), "degradation_percent": None, + "configured_degradation": round((1 - self.battery_scaling) * 100, 2), "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "measurement", @@ -658,7 +659,11 @@ def update_soc_max_calculated_sensor(self, found_size, nominal_capacity=0): found_size_str = "{:.2f} kWh".format(found_size) if found_size is not None else "None" degradation = (self.nominal_capacity - trimmed_mean) / self.nominal_capacity if self.nominal_capacity > 0 else 0 - self.log("Inverter {} battery size tracking: found_size {}, history {}, trimmed_mean {:.2f} kWh, degradation {:.2%}".format(self.id, found_size_str, history, trimmed_mean, degradation)) + self.log( + "Inverter {} battery size tracking: found_size {}, history {}, trimmed_mean {:.2f} kWh, degradation {:.2%}, configured battery_scaling {:.0f}% (configured degradation {:.0f}%)".format( + self.id, found_size_str, history, trimmed_mean, degradation, self.battery_scaling * 100, (1 - self.battery_scaling) * 100 + ) + ) self.base.dashboard_item( sensor_name, @@ -667,6 +672,7 @@ def update_soc_max_calculated_sensor(self, found_size, nominal_capacity=0): "history": history, "nominal_capacity": round(nominal_capacity, 3), "degradation_percent": round(degradation * 100, 2), + "configured_degradation": round((1 - self.battery_scaling) * 100, 2), "unit_of_measurement": "kWh", "device_class": "energy", "state_class": "measurement", @@ -700,7 +706,7 @@ def find_battery_size(self, nominal_capacity=0): clean_increment=False, smoothing=False, divide_by=1.0, - scale=self.battery_scaling, + scale=1.0, required_unit="%", ) else: diff --git a/apps/predbat/tests/test_find_battery_size.py b/apps/predbat/tests/test_find_battery_size.py index cb97dd77c..da3ddd9f0 100644 --- a/apps/predbat/tests/test_find_battery_size.py +++ b/apps/predbat/tests/test_find_battery_size.py @@ -783,6 +783,7 @@ def test_battery_size_tracking_none_stored_on_failure(my_predbat): # Mock find_battery_size to always fail inv.find_battery_size = lambda _nc=0: None + original_soc_max = inv.soc_max # 10.0 try: inv.battery_size_tracking() @@ -799,6 +800,13 @@ def test_battery_size_tracking_none_stored_on_failure(my_predbat): failed = True else: print("SUCCESS: None correctly stored in history to prevent re-calculation") + + # soc_max must remain unchanged when find_battery_size fails and battery_scaling_auto is off + if inv.soc_max != original_soc_max: + print("ERROR: soc_max changed from {:.3f} to {:.3f} when it should remain unchanged after a failed find_battery_size".format(original_soc_max, inv.soc_max)) + failed = True + else: + print("SUCCESS: soc_max remains {:.3f} kWh after failed find_battery_size".format(inv.soc_max)) except Exception as e: print("ERROR: test raised exception: {}".format(e)) import traceback @@ -960,6 +968,66 @@ def test_update_soc_max_calculated_sensor_mixed_none(my_predbat): return failed +def test_find_battery_size_with_scaling(my_predbat): + """ + Test find_battery_size with battery_scaling < 1.0 (80% depth-of-discharge). + + A 10 kWh nominal battery with battery_scaling=0.8 has a usable capacity of 8 kWh. + The SoC sensor reports 0-100% of that 8 kWh usable range. + find_battery_size must return ~8 kWh, not ~10 kWh. + + This regression test catches the bug where battery_scaling was applied to the SoC + percentage values inside find_battery_size, causing the nominal capacity to be + returned instead of the usable (scaled) capacity. + """ + print("*** Running test: find_battery_size_with_scaling ***") + failed = False + + nominal_kwh = 10.0 + battery_scaling = 0.8 + usable_kwh = nominal_kwh * battery_scaling # 8 kWh + + inv = Inverter(my_predbat, 0) + inv.battery_rate_max_charge = 2600 / 60000 + inv.battery_rate_max_discharge = 2600 / 60000 + inv.soc_max = usable_kwh + inv.nominal_capacity = nominal_kwh + inv.battery_scaling = battery_scaling + + setup_predbat(my_predbat) + + # History data reflects 0-100% SoC of the usable 8 kWh range + create_test_history_data(my_predbat, num_days=2, battery_size_kwh=usable_kwh) + + try: + estimated_size = inv.find_battery_size(nominal_kwh) + if estimated_size is None: + print("ERROR: find_battery_size returned None; expected ~{} kWh".format(usable_kwh)) + failed = True + else: + print("Estimated battery size: {:.2f} kWh (expected: {:.2f} kWh usable, nominal {:.2f} kWh)".format(estimated_size, usable_kwh, nominal_kwh)) + tolerance = 0.05 # 5% tolerance since scaling should be applied correctly + lower_bound = usable_kwh * (1 - tolerance) + upper_bound = usable_kwh * (1 + tolerance) + if not (lower_bound <= estimated_size <= upper_bound): + print( + "ERROR: Estimated size {:.2f} kWh is outside usable range [{:.2f}, {:.2f}] kWh. " + "If this is ~{:.2f} kWh the battery_scaling bug is present (SoC percent was scaled " + "inside find_battery_size, returning nominal instead of usable capacity).".format(estimated_size, lower_bound, upper_bound, nominal_kwh) + ) + failed = True + else: + print("SUCCESS: find_battery_size returned usable capacity {:.2f} kWh (within 5% of {:.2f} kWh)".format(estimated_size, usable_kwh)) + except Exception as e: + print("ERROR: find_battery_size raised exception: {}".format(e)) + import traceback + + traceback.print_exc() + failed = True + + return failed + + def test_find_battery_size_soc_kw_unavailable(my_predbat): """ Test find_battery_size when soc_kw history contains 'unavailable' entries. @@ -1033,6 +1101,7 @@ def patched_get_history(entity_id, now=None, days=30): # Restore soc_percent for subsequent tests setup_predbat(my_predbat) + return failed @@ -1133,5 +1202,9 @@ def run_find_battery_size_tests(my_predbat): if failed: return failed + failed |= test_find_battery_size_with_scaling(my_predbat) + if failed: + return failed + print("**** find_battery_size tests completed ****") return failed