Skip to content
Merged
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
37 changes: 37 additions & 0 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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
Expand Down
179 changes: 179 additions & 0 deletions apps/predbat/tests/test_inverter_config_sensor.py
Original file line number Diff line number Diff line change
@@ -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.<prefix>_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
2 changes: 2 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.<prefix>_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),
Expand Down
26 changes: 26 additions & 0 deletions docs/output-data.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading