Skip to content

Commit 28f94f8

Browse files
Merge pull request #4655 from springfall2008/feat/publish-inverter-config
feat(output): publish aggregated inverter config as a sensor
2 parents cd84fc9 + 8eacb0b commit 28f94f8

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