From 2d5559fb2de3bd2d705e2610d3a12fab8ccf5034 Mon Sep 17 00:00:00 2001 From: Trefor Southwell Date: Sat, 22 Aug 2026 19:15:51 +0100 Subject: [PATCH 1/2] feat(output): publish aggregated inverter config as a sensor The static inputs the prediction runs from - the AC and battery power limits, the system topology and the planning scalars - are aggregated across every inverter in fetch_inverter_data() but were never published. Anything backed by CONFIG_ITEMS already has an input_number/switch/select entity, but these come from apps.yaml or are read off the inverters, so the only way to see them was to turn on debug logging and read the log. Publish them as sensor._inverter_config. The state is the fleet AC inverter limit in kW, which makes the headline number graphable, and the rest of the detail sits in the attributes. Power values are held internally in kW per minute and are converted to kW here to match the existing power sensors, which also carry device_class power. Note the export_limit attribute is the inverter's AC export power cap, which is a different thing to the predbat.export_limit plan sensor. Co-Authored-By: Claude Opus 5 (1M context) --- apps/predbat/execute.py | 37 ++++ .../tests/test_inverter_config_sensor.py | 179 ++++++++++++++++++ apps/predbat/unit_test.py | 2 + docs/output-data.md | 26 +++ 4 files changed, 244 insertions(+) create mode 100644 apps/predbat/tests/test_inverter_config_sensor.py diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 3bd7f28fd..6249b964e 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -1004,6 +1004,7 @@ def fetch_inverter_data(self, create=True): self.charge_limit = [self.current_charge_limit * self.soc_max / 100.0 for i in range(len(self.charge_window))] self.publish_charge_limit(self.charge_limit, self.charge_window, best=False) self.publish_inverter_data() + self.publish_inverter_config() return True def quick_inverter_data_update(self): @@ -1066,6 +1067,42 @@ def publish_inverter_data(self): }, ) + def publish_inverter_config(self): + """ + Publish the static configuration the prediction runs from, aggregated over all the inverters + + These come from apps.yaml or are read back off the inverters, so unlike the settings in + CONFIG_ITEMS they have no entity of their own. Power values are held internally in kW per + minute and are converted to kW here to match the other power sensors. + """ + self.dashboard_item( + "sensor." + self.prefix + "_inverter_config", + state=dp3(self.inverter_limit * MINUTE_WATT / 1000.0), + attributes={ + "friendly_name": "Predbat Inverter Config", + "state_class": "measurement", + "unit_of_measurement": "kW", + "device_class": "power", + "icon": "mdi:transmission-tower", + "inverter_limit": dp3(self.inverter_limit * MINUTE_WATT / 1000.0), + "export_limit": dp3(self.export_limit * MINUTE_WATT / 1000.0), + "pv_ac_limit": dp3(self.pv_ac_limit * MINUTE_WATT / 1000.0), + "battery_rate_max_charge": dp3(self.battery_rate_max_charge * MINUTE_WATT / 1000.0), + "battery_rate_max_charge_dc": dp3(self.battery_rate_max_charge_dc * MINUTE_WATT / 1000.0), + "battery_rate_max_discharge": dp3(self.battery_rate_max_discharge * MINUTE_WATT / 1000.0), + "battery_rate_max_export": dp3(self.battery_rate_max_export * MINUTE_WATT / 1000.0), + "battery_rate_min": dp3(self.battery_rate_min * MINUTE_WATT / 1000.0), + "soc_max": dp3(self.soc_max), + "reserve": dp3(self.reserve), + "num_inverters": self.num_inverters, + "num_cars": self.num_cars, + "inverter_can_charge_during_export": self.inverter_can_charge_during_export, + "metric_standing_charge": dp2(self.metric_standing_charge), + "forecast_minutes": self.forecast_minutes, + "plan_interval_minutes": self.plan_interval_minutes, + }, + ) + def balance_inverters(self, test_mode=False): """ Attempt to balance multiple inverters diff --git a/apps/predbat/tests/test_inverter_config_sensor.py b/apps/predbat/tests/test_inverter_config_sensor.py new file mode 100644 index 000000000..f199c07b4 --- /dev/null +++ b/apps/predbat/tests/test_inverter_config_sensor.py @@ -0,0 +1,179 @@ +# ----------------------------------------------------------------------------- +# Predbat Home Battery System +# Copyright Trefor Southwell 2026 - All Rights Reserved +# This application maybe used for personal use only and not for commercial use +# ----------------------------------------------------------------------------- +# fmt off +# pylint: disable=consider-using-f-string +# pylint: disable=line-too-long +# pylint: disable=attribute-defined-outside-init + +from const import MINUTE_WATT +from tests.test_infra import reset_inverter, TestInverter + + +def make_stub_inverter(id, inverter_limit_watts): + """Build an inverter stub carrying just the values fetch_inverter_data() aggregates""" + inverter = TestInverter() + inverter.id = id + inverter.update_status = lambda minutes_now, quiet=False: None + inverter.charge_window = [] + inverter.export_window = [] + inverter.export_limits = [] + inverter.inv_support_discharge_freeze = True + inverter.inv_support_charge_freeze = True + inverter.inv_has_reserve_soc = True + inverter.current_charge_limit = 100.0 + inverter.soc_max = 5.0 + inverter.soc_kw = 2.0 + inverter.reserve = 0.5 + inverter.reserve_current = 0.5 + inverter.battery_rate_max_charge = 2000 / MINUTE_WATT + inverter.battery_rate_max_charge_dc = 2000 / MINUTE_WATT + inverter.battery_rate_max_discharge = 2000 / MINUTE_WATT + inverter.battery_rate_max_export = 2000 / MINUTE_WATT + inverter.charge_rate_now = 2000 / MINUTE_WATT + inverter.discharge_rate_now = 2000 / MINUTE_WATT + inverter.battery_rate_min = 0 + inverter.inverter_limit = inverter_limit_watts / MINUTE_WATT + inverter.export_limit = inverter_limit_watts / MINUTE_WATT + inverter.pv_power = 0 + inverter.load_power = 0 + inverter.battery_power = 0 + inverter.grid_power = 0 + inverter.battery_temperature = 20 + return inverter + + +def test_inverter_config_sensor(my_predbat): + """ + The static inputs the prediction runs from - the AC/battery power limits, the system topology + and the planning scalars - are aggregated across all inverters but were never published, so a + user could only see them by turning on debug logging. publish_inverter_config() exposes them as + attributes of a single sensor. + """ + # Every test in the suite shares one PredBat instance, and to prove each value is published from + # the attribute it claims to come from this test has to move them all away from the fixture + # defaults. Snapshot the attribute bindings up front and put them back before returning, so none + # of that reaches the next test - a leaked 48 hour forecast_minutes ran the C++ prediction kernel + # off the end of the fixture's 24 hours of step data and crashed model_kernel. + saved_state = dict(my_predbat.__dict__) + args_had_num_inverters = "num_inverters" in my_predbat.args + saved_num_inverters = my_predbat.args.get("num_inverters", None) + try: + failed = run_inverter_config_checks(my_predbat) + finally: + my_predbat.__dict__.clear() + my_predbat.__dict__.update(saved_state) + if args_had_num_inverters: + my_predbat.args["num_inverters"] = saved_num_inverters + else: + my_predbat.args.pop("num_inverters", None) + return failed + + +def run_inverter_config_checks(my_predbat): + """Check sensor._inverter_config against known values, leaving my_predbat dirty""" + reset_inverter(my_predbat) + failed = False + + my_predbat.inverter_limit = 6000 / MINUTE_WATT + my_predbat.export_limit = 5000 / MINUTE_WATT + my_predbat.pv_ac_limit = 3600 / MINUTE_WATT + my_predbat.battery_rate_max_charge = 2500 / MINUTE_WATT + my_predbat.battery_rate_max_charge_dc = 4000 / MINUTE_WATT + my_predbat.battery_rate_max_discharge = 2600 / MINUTE_WATT + my_predbat.battery_rate_max_export = 2400 / MINUTE_WATT + my_predbat.battery_rate_min = 200 / MINUTE_WATT + my_predbat.soc_max = 9.52 + my_predbat.reserve = 0.95 + my_predbat.num_inverters = 2 + my_predbat.num_cars = 1 + my_predbat.inverter_can_charge_during_export = False + my_predbat.metric_standing_charge = 42.5 + my_predbat.forecast_minutes = 48 * 60 + my_predbat.plan_interval_minutes = 30 + + my_predbat.publish_inverter_config() + + entity_id = "sensor." + my_predbat.prefix + "_inverter_config" + item = my_predbat.ha_interface.dummy_items.get(entity_id) + if item is None: + print("ERROR: {} was not published".format(entity_id)) + return True + + print("Test: state is the AC inverter limit in kW with a power device_class") + expect_meta = { + "state": 6.0, + "friendly_name": "Predbat Inverter Config", + "state_class": "measurement", + "unit_of_measurement": "kW", + "device_class": "power", + } + for key, value in expect_meta.items(): + if item.get(key, None) != value: + print("ERROR: {} {} is {} expected {}".format(entity_id, key, item.get(key, None), value)) + failed = True + + print("Test: power limits are published in kW, converted from the internal kW/minute form") + expect_power = { + "inverter_limit": 6.0, + "export_limit": 5.0, + "pv_ac_limit": 3.6, + "battery_rate_max_charge": 2.5, + "battery_rate_max_charge_dc": 4.0, + "battery_rate_max_discharge": 2.6, + "battery_rate_max_export": 2.4, + "battery_rate_min": 0.2, + } + for key, value in expect_power.items(): + if item.get(key, None) != value: + print("ERROR: {} attribute {} is {} expected {}".format(entity_id, key, item.get(key, None), value)) + failed = True + + print("Test: capacity, topology and planning scalars are published as-is") + expect_plain = { + "soc_max": 9.52, + "reserve": 0.95, + "num_inverters": 2, + "num_cars": 1, + "inverter_can_charge_during_export": False, + "metric_standing_charge": 42.5, + "forecast_minutes": 48 * 60, + "plan_interval_minutes": 30, + } + for key, value in expect_plain.items(): + if item.get(key, None) != value: + print("ERROR: {} attribute {} is {} expected {}".format(entity_id, key, item.get(key, None), value)) + failed = True + + print("Test: a zero limit still publishes rather than being dropped") + my_predbat.pv_ac_limit = 0 + my_predbat.publish_inverter_config() + item = my_predbat.ha_interface.dummy_items.get(entity_id) + if item.get("pv_ac_limit", None) != 0: + print("ERROR: {} attribute pv_ac_limit is {} expected 0".format(entity_id, item.get("pv_ac_limit", None))) + failed = True + + print("Test: a normal inverter fetch publishes the sensor with the fleet totals") + my_predbat.args["num_inverters"] = 2 + my_predbat.inverters = [make_stub_inverter(0, 3000), make_stub_inverter(1, 3000)] + my_predbat.computed_charge_curve = True + my_predbat.computed_discharge_curve = True + my_predbat.battery_charge_power_curve_auto = False + my_predbat.battery_discharge_power_curve_auto = False + my_predbat.ha_interface.dummy_items.pop(entity_id, None) + + my_predbat.fetch_inverter_data(create=False) + + item = my_predbat.ha_interface.dummy_items.get(entity_id) + if item is None: + print("ERROR: {} was not published by fetch_inverter_data".format(entity_id)) + failed = True + else: + for key, value in {"state": 6.0, "inverter_limit": 6.0, "export_limit": 6.0, "soc_max": 10.0, "reserve": 1.0, "num_inverters": 2}.items(): + if item.get(key, None) != value: + print("ERROR: {} {} is {} expected {} after fetch_inverter_data".format(entity_id, key, item.get(key, None), value)) + failed = True + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index 5abbaa9d5..8a019a4fa 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -28,6 +28,7 @@ from tests.test_model import run_model_tests from tests.test_predict_pv_power import run_predict_pv_power_tests from tests.test_dashboard_device_class import test_dashboard_device_class +from tests.test_inverter_config_sensor import test_inverter_config_sensor from tests.test_kernel_parity import run_kernel_parity_tests, run_model_kernel_tests from tests.test_prediction_batch import run_prediction_batch_tests from tests.test_kernel_static_cache import run_kernel_static_cache_tests @@ -352,6 +353,7 @@ def main(): ("model", run_model_tests, "Model tests", False), ("predict_pv_power", run_predict_pv_power_tests, "predict_pv_power plan-interval scaling tests", False), ("dashboard_device_class", test_dashboard_device_class, "Dashboard sensor device_class regression tests (#3352)", False), + ("inverter_config_sensor", test_inverter_config_sensor, "Aggregated static prediction inputs published as sensor._inverter_config", False), ("model_kernel", run_model_kernel_tests, "Model tests run with the C++ prediction kernel enabled", False), ("kernel_parity", run_kernel_parity_tests, "C++ prediction kernel vs Python engine parity tests", False), ("prediction_batch", run_prediction_batch_tests, "Batched prediction fan-out tests", False), diff --git a/docs/output-data.md b/docs/output-data.md index 35f79dab0..e80082119 100644 --- a/docs/output-data.md +++ b/docs/output-data.md @@ -469,6 +469,32 @@ These are useful for automations if for example, you want to turn off car chargi ## Inverter data +**sensor.predbat_inverter_config** reports the static configuration that Predbat plans against, totalled across all of your inverters. +These values are read from `apps.yaml` or from the inverters themselves, so unlike the [Predbat control settings](customisation.md) they have no entity of their own. +The sensor state is the total AC inverter limit in kW, with the rest of the detail held in the attributes: + +| Attribute | Meaning | +|-----------|---------| +| inverter_limit | Total AC throughput limit in kW - see [inverter_limit](apps-yaml.md#inverter_limit) | +| export_limit | Total AC export limit in kW - see [export_limit](apps-yaml.md#export_limit). Note this is your inverter's power cap and is a different thing to the predbat.export_limit plan sensor | +| pv_ac_limit | Modelled AC output limit of an AC-coupled PV system in kW - see [pv_ac_limit](apps-yaml.md#pv_ac_limit) | +| battery_rate_max_charge | Maximum battery charge rate in kW | +| battery_rate_max_charge_dc | Maximum DC (solar) battery charge rate in kW | +| battery_rate_max_discharge | Maximum battery discharge rate in kW | +| battery_rate_max_export | Maximum battery export rate in kW | +| battery_rate_min | Minimum battery charge/discharge rate in kW | +| soc_max | Total battery capacity in kWh | +| reserve | Battery reserve in kWh | +| num_inverters | Number of inverters | +| num_cars | Number of cars Predbat is planning for | +| inverter_can_charge_during_export | Whether the battery can be charged while the inverter is exporting | +| metric_standing_charge | Daily standing charge | +| forecast_minutes | Length of the forecast horizon in minutes | +| plan_interval_minutes | Length of one slot in the plan in minutes | + +The power figures are the totals across your fleet, so with two 3 kW inverters the reported inverter_limit is 6 kW. +This sensor is worth checking first when a plan looks wrong, as an incorrect inverter_limit or battery rate quietly shapes every charge and export window. + Some inverters store inverter settings in [flash memory that can have a limited number of write cycles](caution.md#flash-memory) so Predbat counts the commands that it sends to the inverter so you can keep track of this: - predbat.inverter_register_writes is the incrementing total number of writes across all inverters From 8eacb0b018c563b2cd351711a55eb45c9347d73c Mon Sep 17 00:00:00 2001 From: Trefor Southwell <48591903+springfall2008@users.noreply.github.com> Date: Sat, 22 Aug 2026 19:20:32 +0100 Subject: [PATCH 2/2] Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- apps/predbat/tests/test_inverter_config_sensor.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/predbat/tests/test_inverter_config_sensor.py b/apps/predbat/tests/test_inverter_config_sensor.py index f199c07b4..58108f2e1 100644 --- a/apps/predbat/tests/test_inverter_config_sensor.py +++ b/apps/predbat/tests/test_inverter_config_sensor.py @@ -12,10 +12,10 @@ from tests.test_infra import reset_inverter, TestInverter -def make_stub_inverter(id, inverter_limit_watts): +def make_stub_inverter(inverter_id, inverter_limit_watts): """Build an inverter stub carrying just the values fetch_inverter_data() aggregates""" inverter = TestInverter() - inverter.id = id + inverter.id = inverter_id inverter.update_status = lambda minutes_now, quiet=False: None inverter.charge_window = [] inverter.export_window = []