Skip to content

Commit 2d5559f

Browse files
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.<prefix>_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) <noreply@anthropic.com>
1 parent cd84fc9 commit 2d5559f

4 files changed

Lines changed: 244 additions & 0 deletions

File tree

apps/predbat/execute.py

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1004,6 +1004,7 @@ def fetch_inverter_data(self, create=True):
10041004
self.charge_limit = [self.current_charge_limit * self.soc_max / 100.0 for i in range(len(self.charge_window))]
10051005
self.publish_charge_limit(self.charge_limit, self.charge_window, best=False)
10061006
self.publish_inverter_data()
1007+
self.publish_inverter_config()
10071008
return True
10081009

10091010
def quick_inverter_data_update(self):
@@ -1066,6 +1067,42 @@ def publish_inverter_data(self):
10661067
},
10671068
)
10681069

1070+
def publish_inverter_config(self):
1071+
"""
1072+
Publish the static configuration the prediction runs from, aggregated over all the inverters
1073+
1074+
These come from apps.yaml or are read back off the inverters, so unlike the settings in
1075+
CONFIG_ITEMS they have no entity of their own. Power values are held internally in kW per
1076+
minute and are converted to kW here to match the other power sensors.
1077+
"""
1078+
self.dashboard_item(
1079+
"sensor." + self.prefix + "_inverter_config",
1080+
state=dp3(self.inverter_limit * MINUTE_WATT / 1000.0),
1081+
attributes={
1082+
"friendly_name": "Predbat Inverter Config",
1083+
"state_class": "measurement",
1084+
"unit_of_measurement": "kW",
1085+
"device_class": "power",
1086+
"icon": "mdi:transmission-tower",
1087+
"inverter_limit": dp3(self.inverter_limit * MINUTE_WATT / 1000.0),
1088+
"export_limit": dp3(self.export_limit * MINUTE_WATT / 1000.0),
1089+
"pv_ac_limit": dp3(self.pv_ac_limit * MINUTE_WATT / 1000.0),
1090+
"battery_rate_max_charge": dp3(self.battery_rate_max_charge * MINUTE_WATT / 1000.0),
1091+
"battery_rate_max_charge_dc": dp3(self.battery_rate_max_charge_dc * MINUTE_WATT / 1000.0),
1092+
"battery_rate_max_discharge": dp3(self.battery_rate_max_discharge * MINUTE_WATT / 1000.0),
1093+
"battery_rate_max_export": dp3(self.battery_rate_max_export * MINUTE_WATT / 1000.0),
1094+
"battery_rate_min": dp3(self.battery_rate_min * MINUTE_WATT / 1000.0),
1095+
"soc_max": dp3(self.soc_max),
1096+
"reserve": dp3(self.reserve),
1097+
"num_inverters": self.num_inverters,
1098+
"num_cars": self.num_cars,
1099+
"inverter_can_charge_during_export": self.inverter_can_charge_during_export,
1100+
"metric_standing_charge": dp2(self.metric_standing_charge),
1101+
"forecast_minutes": self.forecast_minutes,
1102+
"plan_interval_minutes": self.plan_interval_minutes,
1103+
},
1104+
)
1105+
10691106
def balance_inverters(self, test_mode=False):
10701107
"""
10711108
Attempt to balance multiple inverters
Lines changed: 179 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,179 @@
1+
# -----------------------------------------------------------------------------
2+
# Predbat Home Battery System
3+
# Copyright Trefor Southwell 2026 - All Rights Reserved
4+
# This application maybe used for personal use only and not for commercial use
5+
# -----------------------------------------------------------------------------
6+
# fmt off
7+
# pylint: disable=consider-using-f-string
8+
# pylint: disable=line-too-long
9+
# pylint: disable=attribute-defined-outside-init
10+
11+
from const import MINUTE_WATT
12+
from tests.test_infra import reset_inverter, TestInverter
13+
14+
15+
def make_stub_inverter(id, inverter_limit_watts):
16+
"""Build an inverter stub carrying just the values fetch_inverter_data() aggregates"""
17+
inverter = TestInverter()
18+
inverter.id = id
19+
inverter.update_status = lambda minutes_now, quiet=False: None
20+
inverter.charge_window = []
21+
inverter.export_window = []
22+
inverter.export_limits = []
23+
inverter.inv_support_discharge_freeze = True
24+
inverter.inv_support_charge_freeze = True
25+
inverter.inv_has_reserve_soc = True
26+
inverter.current_charge_limit = 100.0
27+
inverter.soc_max = 5.0
28+
inverter.soc_kw = 2.0
29+
inverter.reserve = 0.5
30+
inverter.reserve_current = 0.5
31+
inverter.battery_rate_max_charge = 2000 / MINUTE_WATT
32+
inverter.battery_rate_max_charge_dc = 2000 / MINUTE_WATT
33+
inverter.battery_rate_max_discharge = 2000 / MINUTE_WATT
34+
inverter.battery_rate_max_export = 2000 / MINUTE_WATT
35+
inverter.charge_rate_now = 2000 / MINUTE_WATT
36+
inverter.discharge_rate_now = 2000 / MINUTE_WATT
37+
inverter.battery_rate_min = 0
38+
inverter.inverter_limit = inverter_limit_watts / MINUTE_WATT
39+
inverter.export_limit = inverter_limit_watts / MINUTE_WATT
40+
inverter.pv_power = 0
41+
inverter.load_power = 0
42+
inverter.battery_power = 0
43+
inverter.grid_power = 0
44+
inverter.battery_temperature = 20
45+
return inverter
46+
47+
48+
def test_inverter_config_sensor(my_predbat):
49+
"""
50+
The static inputs the prediction runs from - the AC/battery power limits, the system topology
51+
and the planning scalars - are aggregated across all inverters but were never published, so a
52+
user could only see them by turning on debug logging. publish_inverter_config() exposes them as
53+
attributes of a single sensor.
54+
"""
55+
# Every test in the suite shares one PredBat instance, and to prove each value is published from
56+
# the attribute it claims to come from this test has to move them all away from the fixture
57+
# defaults. Snapshot the attribute bindings up front and put them back before returning, so none
58+
# of that reaches the next test - a leaked 48 hour forecast_minutes ran the C++ prediction kernel
59+
# off the end of the fixture's 24 hours of step data and crashed model_kernel.
60+
saved_state = dict(my_predbat.__dict__)
61+
args_had_num_inverters = "num_inverters" in my_predbat.args
62+
saved_num_inverters = my_predbat.args.get("num_inverters", None)
63+
try:
64+
failed = run_inverter_config_checks(my_predbat)
65+
finally:
66+
my_predbat.__dict__.clear()
67+
my_predbat.__dict__.update(saved_state)
68+
if args_had_num_inverters:
69+
my_predbat.args["num_inverters"] = saved_num_inverters
70+
else:
71+
my_predbat.args.pop("num_inverters", None)
72+
return failed
73+
74+
75+
def run_inverter_config_checks(my_predbat):
76+
"""Check sensor.<prefix>_inverter_config against known values, leaving my_predbat dirty"""
77+
reset_inverter(my_predbat)
78+
failed = False
79+
80+
my_predbat.inverter_limit = 6000 / MINUTE_WATT
81+
my_predbat.export_limit = 5000 / MINUTE_WATT
82+
my_predbat.pv_ac_limit = 3600 / MINUTE_WATT
83+
my_predbat.battery_rate_max_charge = 2500 / MINUTE_WATT
84+
my_predbat.battery_rate_max_charge_dc = 4000 / MINUTE_WATT
85+
my_predbat.battery_rate_max_discharge = 2600 / MINUTE_WATT
86+
my_predbat.battery_rate_max_export = 2400 / MINUTE_WATT
87+
my_predbat.battery_rate_min = 200 / MINUTE_WATT
88+
my_predbat.soc_max = 9.52
89+
my_predbat.reserve = 0.95
90+
my_predbat.num_inverters = 2
91+
my_predbat.num_cars = 1
92+
my_predbat.inverter_can_charge_during_export = False
93+
my_predbat.metric_standing_charge = 42.5
94+
my_predbat.forecast_minutes = 48 * 60
95+
my_predbat.plan_interval_minutes = 30
96+
97+
my_predbat.publish_inverter_config()
98+
99+
entity_id = "sensor." + my_predbat.prefix + "_inverter_config"
100+
item = my_predbat.ha_interface.dummy_items.get(entity_id)
101+
if item is None:
102+
print("ERROR: {} was not published".format(entity_id))
103+
return True
104+
105+
print("Test: state is the AC inverter limit in kW with a power device_class")
106+
expect_meta = {
107+
"state": 6.0,
108+
"friendly_name": "Predbat Inverter Config",
109+
"state_class": "measurement",
110+
"unit_of_measurement": "kW",
111+
"device_class": "power",
112+
}
113+
for key, value in expect_meta.items():
114+
if item.get(key, None) != value:
115+
print("ERROR: {} {} is {} expected {}".format(entity_id, key, item.get(key, None), value))
116+
failed = True
117+
118+
print("Test: power limits are published in kW, converted from the internal kW/minute form")
119+
expect_power = {
120+
"inverter_limit": 6.0,
121+
"export_limit": 5.0,
122+
"pv_ac_limit": 3.6,
123+
"battery_rate_max_charge": 2.5,
124+
"battery_rate_max_charge_dc": 4.0,
125+
"battery_rate_max_discharge": 2.6,
126+
"battery_rate_max_export": 2.4,
127+
"battery_rate_min": 0.2,
128+
}
129+
for key, value in expect_power.items():
130+
if item.get(key, None) != value:
131+
print("ERROR: {} attribute {} is {} expected {}".format(entity_id, key, item.get(key, None), value))
132+
failed = True
133+
134+
print("Test: capacity, topology and planning scalars are published as-is")
135+
expect_plain = {
136+
"soc_max": 9.52,
137+
"reserve": 0.95,
138+
"num_inverters": 2,
139+
"num_cars": 1,
140+
"inverter_can_charge_during_export": False,
141+
"metric_standing_charge": 42.5,
142+
"forecast_minutes": 48 * 60,
143+
"plan_interval_minutes": 30,
144+
}
145+
for key, value in expect_plain.items():
146+
if item.get(key, None) != value:
147+
print("ERROR: {} attribute {} is {} expected {}".format(entity_id, key, item.get(key, None), value))
148+
failed = True
149+
150+
print("Test: a zero limit still publishes rather than being dropped")
151+
my_predbat.pv_ac_limit = 0
152+
my_predbat.publish_inverter_config()
153+
item = my_predbat.ha_interface.dummy_items.get(entity_id)
154+
if item.get("pv_ac_limit", None) != 0:
155+
print("ERROR: {} attribute pv_ac_limit is {} expected 0".format(entity_id, item.get("pv_ac_limit", None)))
156+
failed = True
157+
158+
print("Test: a normal inverter fetch publishes the sensor with the fleet totals")
159+
my_predbat.args["num_inverters"] = 2
160+
my_predbat.inverters = [make_stub_inverter(0, 3000), make_stub_inverter(1, 3000)]
161+
my_predbat.computed_charge_curve = True
162+
my_predbat.computed_discharge_curve = True
163+
my_predbat.battery_charge_power_curve_auto = False
164+
my_predbat.battery_discharge_power_curve_auto = False
165+
my_predbat.ha_interface.dummy_items.pop(entity_id, None)
166+
167+
my_predbat.fetch_inverter_data(create=False)
168+
169+
item = my_predbat.ha_interface.dummy_items.get(entity_id)
170+
if item is None:
171+
print("ERROR: {} was not published by fetch_inverter_data".format(entity_id))
172+
failed = True
173+
else:
174+
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():
175+
if item.get(key, None) != value:
176+
print("ERROR: {} {} is {} expected {} after fetch_inverter_data".format(entity_id, key, item.get(key, None), value))
177+
failed = True
178+
179+
return failed

apps/predbat/unit_test.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@
2828
from tests.test_model import run_model_tests
2929
from tests.test_predict_pv_power import run_predict_pv_power_tests
3030
from tests.test_dashboard_device_class import test_dashboard_device_class
31+
from tests.test_inverter_config_sensor import test_inverter_config_sensor
3132
from tests.test_kernel_parity import run_kernel_parity_tests, run_model_kernel_tests
3233
from tests.test_prediction_batch import run_prediction_batch_tests
3334
from tests.test_kernel_static_cache import run_kernel_static_cache_tests
@@ -352,6 +353,7 @@ def main():
352353
("model", run_model_tests, "Model tests", False),
353354
("predict_pv_power", run_predict_pv_power_tests, "predict_pv_power plan-interval scaling tests", False),
354355
("dashboard_device_class", test_dashboard_device_class, "Dashboard sensor device_class regression tests (#3352)", False),
356+
("inverter_config_sensor", test_inverter_config_sensor, "Aggregated static prediction inputs published as sensor.<prefix>_inverter_config", False),
355357
("model_kernel", run_model_kernel_tests, "Model tests run with the C++ prediction kernel enabled", False),
356358
("kernel_parity", run_kernel_parity_tests, "C++ prediction kernel vs Python engine parity tests", False),
357359
("prediction_batch", run_prediction_batch_tests, "Batched prediction fan-out tests", False),

docs/output-data.md

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -469,6 +469,32 @@ These are useful for automations if for example, you want to turn off car chargi
469469

470470
## Inverter data
471471

472+
**sensor.predbat_inverter_config** reports the static configuration that Predbat plans against, totalled across all of your inverters.
473+
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.
474+
The sensor state is the total AC inverter limit in kW, with the rest of the detail held in the attributes:
475+
476+
| Attribute | Meaning |
477+
|-----------|---------|
478+
| inverter_limit | Total AC throughput limit in kW - see [inverter_limit](apps-yaml.md#inverter_limit) |
479+
| 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 |
480+
| 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) |
481+
| battery_rate_max_charge | Maximum battery charge rate in kW |
482+
| battery_rate_max_charge_dc | Maximum DC (solar) battery charge rate in kW |
483+
| battery_rate_max_discharge | Maximum battery discharge rate in kW |
484+
| battery_rate_max_export | Maximum battery export rate in kW |
485+
| battery_rate_min | Minimum battery charge/discharge rate in kW |
486+
| soc_max | Total battery capacity in kWh |
487+
| reserve | Battery reserve in kWh |
488+
| num_inverters | Number of inverters |
489+
| num_cars | Number of cars Predbat is planning for |
490+
| inverter_can_charge_during_export | Whether the battery can be charged while the inverter is exporting |
491+
| metric_standing_charge | Daily standing charge |
492+
| forecast_minutes | Length of the forecast horizon in minutes |
493+
| plan_interval_minutes | Length of one slot in the plan in minutes |
494+
495+
The power figures are the totals across your fleet, so with two 3 kW inverters the reported inverter_limit is 6 kW.
496+
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.
497+
472498
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:
473499

474500
- predbat.inverter_register_writes is the incrementing total number of writes across all inverters

0 commit comments

Comments
 (0)