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..58108f2e1 --- /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(inverter_id, inverter_limit_watts): + """Build an inverter stub carrying just the values fetch_inverter_data() aggregates""" + inverter = TestInverter() + inverter.id = inverter_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