Skip to content

Commit 6a01eb4

Browse files
committed
Bound the solar surplus by the CT clamp setting and by generation
Two ways the sensor could report a surplus that was not there. A charger outside the CT clamp is never seen by the inverter's grid reading, so adding car_charging_power back invented a surplus equal to the whole charger draw. The add-back is now gated on car_energy_reported_load, the flag prediction.py already uses for this. car_charging_power_included says which applies. The result is capped at pv_power. A grid sensor wired positive-on-import without grid_power_invert made the surplus track the import instead, holding a charger at full rate. The cap cannot fix the sign in daylight, but it bounds the error and forces 0 after dark. The documented automation had three faults: a bare state trigger on car_charging_slot turned the charger on when the cheap slot ended, charging could not resume after a force export slot because numeric_state only fires on a crossing, and the modulating-current snippet floored at 6A with no off state. Docs now also cover the PV inventory and freshness checks, and correct the update interval to INVERTER_QUICK_UPDATE_SECONDS.
1 parent 2172347 commit 6a01eb4

5 files changed

Lines changed: 152 additions & 60 deletions

File tree

‎apps/predbat/execute.py‎

Lines changed: 17 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1170,18 +1170,22 @@ def publish_inverter_data(self):
11701170
},
11711171
)
11721172

1173-
# Power a flexible load could take right now without importing or draining the battery.
1173+
# Power a load could take now without importing or draining the battery.
11741174
#
1175-
# A car charger sits behind the grid meter, so once a car is charging the export collapses
1176-
# towards zero - car_charging_power is added back to recover what the export would be with
1177-
# the car off, which is what makes this stable enough to drive a charger from. Any other
1178-
# load reading this while a car charges wants the car's share left out, so it can subtract
1179-
# the car_charging_power attribute. Battery discharge is subtracted so battery power is
1180-
# never offered up as if it were solar: if cloud cover arrives mid-charge the battery starts
1181-
# covering the load, and without the subtraction the sensor would keep reporting a surplus
1182-
# that is really coming out of the battery. Battery charging is deliberately not added -
1183-
# what gets the surplus is the user's policy, and the components are in the attributes.
1184-
solar_surplus = max(0.0, self.grid_power + self.car_charging_power - max(0.0, self.battery_power))
1175+
# The car's own draw is added back so the sensor keeps reading what is available for it
1176+
# rather than collapsing once the charger starts. Only when the charger is inside the CT
1177+
# clamp: outside it the grid reading never saw the car, so adding it would invent a surplus
1178+
# equal to the charger draw. prediction.py branches on the same flag.
1179+
car_add_back = self.car_charging_power if self.car_energy_reported_load else 0.0
1180+
1181+
# Subtracting battery discharge keeps battery power from being reported as solar. Battery
1182+
# charging is not added back, so a charging battery takes the surplus first.
1183+
solar_surplus = max(0.0, self.grid_power + car_add_back - max(0.0, self.battery_power))
1184+
1185+
# Nothing can be spare that was never generated. A grid sensor wired positive-on-import
1186+
# without grid_power_invert would otherwise make the surplus track the import, and this
1187+
# sensor switches real load on.
1188+
solar_surplus = min(solar_surplus, max(0.0, self.pv_power))
11851189
self.dashboard_item(
11861190
self.prefix + ".solar_surplus_power",
11871191
state=dp3(solar_surplus / 1000.0),
@@ -1193,8 +1197,10 @@ def publish_inverter_data(self):
11931197
"icon": "mdi:solar-power",
11941198
"grid_power": dp3(self.grid_power / 1000.0),
11951199
"battery_power": dp3(self.battery_power / 1000.0),
1200+
"pv_power": dp3(self.pv_power / 1000.0),
11961201
"car_charging_power": dp3(self.car_charging_power / 1000.0),
11971202
"car_charging_power_configured": self.car_charging_power_configured,
1203+
"car_charging_power_included": bool(self.car_energy_reported_load),
11981204
},
11991205
)
12001206

‎apps/predbat/output.py‎

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -2936,11 +2936,9 @@ def set_charge_export_status(self, isCharging, isExporting, isDemand):
29362936
)
29372937
self.dashboard_item("binary_sensor." + self.prefix + "_demand", state="on" if isDemand else "off", attributes={"friendly_name": "Predbat is in demand mode", "icon": "mdi:battery-arrow-up"})
29382938

2939-
# Whether the plan has a force export window running now, taken from the plan rather than
2940-
# from isExporting above. That makes it stay on right through a slot, cover freeze export
2941-
# (where Predbat is selling the PV rather than the battery) and the Hold exporting phases,
2942-
# and still report in read only mode - all cases where an automation wants to leave the
2943-
# solar for Predbat to sell, but where the commanded state above is off.
2939+
# Taken from the plan, not from isExporting above. isExporting is off in read only mode,
2940+
# off during Hold exporting, and only on for the minutes an export is commanded, whereas an
2941+
# automation needs to know a slot is running for its whole duration including freeze export.
29442942
export_window_n = self.in_charge_window(self.export_window_best, self.minutes_now)
29452943
in_export_slot = self.set_export_window and export_window_n >= 0 and self.export_limits_best[export_window_n] < EXPORT_LIMIT_IDLE
29462944
self.dashboard_item(

‎apps/predbat/tests/test_solar_surplus.py‎

Lines changed: 43 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -15,12 +15,17 @@
1515
EXPORT_SLOT_ENTITY = "_force_export_slot"
1616

1717

18-
def check_surplus(my_predbat, name, grid_power, battery_power, car_charging_power, car_configured, expect_state):
19-
"""Publish the inverter sensors for one power reading and check the surplus state"""
18+
def check_surplus(my_predbat, name, grid_power, battery_power, car_charging_power, car_configured, expect_state, pv_power=20000, car_reported_load=True):
19+
"""Publish the inverter sensors for one power reading and check the surplus state
20+
21+
pv_power defaults high enough not to clip. Cases exercising the clamp pass their own.
22+
"""
2023
my_predbat.grid_power = grid_power
2124
my_predbat.battery_power = battery_power
25+
my_predbat.pv_power = pv_power
2226
my_predbat.car_charging_power = car_charging_power
2327
my_predbat.car_charging_power_configured = car_configured
28+
my_predbat.car_energy_reported_load = car_reported_load
2429
my_predbat.publish_inverter_data()
2530

2631
entity_id = my_predbat.prefix + SURPLUS_ENTITY
@@ -57,8 +62,7 @@ def check_export_slot(my_predbat, name, minutes_now, export_window, export_limit
5762
def run_export_slot_tests(my_predbat):
5863
"""
5964
binary_sensor.predbat_force_export_slot reports whether the plan has a force export window
60-
running now, so an automation can leave the solar for Predbat to sell rather than putting it
61-
into the car.
65+
running, so an automation can leave the solar for Predbat to sell.
6266
"""
6367
failed = False
6468
saved = {key: getattr(my_predbat, key) for key in ["minutes_now", "export_window_best", "export_limits_best", "set_export_window"]}
@@ -101,14 +105,13 @@ def run_export_slot_tests(my_predbat):
101105

102106
def run_solar_surplus_tests(my_predbat):
103107
"""
104-
predbat.solar_surplus_power reports the power a flexible load could take right now without
105-
importing or draining the battery, so a Home Assistant automation can start a car, an immersion
106-
heater or anything else on spare solar without Predbat deciding anything.
108+
predbat.solar_surplus_power reports the power a load could take now without importing or
109+
draining the battery, so an automation can run a car or immersion heater on spare solar.
107110
"""
108111
reset_inverter(my_predbat)
109112
failed = False
110113

111-
saved = {key: getattr(my_predbat, key) for key in ["grid_power", "battery_power", "car_charging_power", "car_charging_power_configured"]}
114+
saved = {key: getattr(my_predbat, key) for key in ["grid_power", "battery_power", "pv_power", "car_charging_power", "car_charging_power_configured", "car_energy_reported_load"]}
112115
try:
113116
print("Test: with no car charging the surplus is the grid export")
114117
failed |= check_surplus(my_predbat, "plain export", grid_power=2000, battery_power=0, car_charging_power=0, car_configured=False, expect_state=2.0)
@@ -119,20 +122,38 @@ def run_solar_surplus_tests(my_predbat):
119122
print("Test: a charging car is added back, so the sensor does not collapse once the car starts")
120123
failed |= check_surplus(my_predbat, "car charging", grid_power=200, battery_power=0, car_charging_power=7000, car_configured=True, expect_state=7.2)
121124

122-
# Cloud cover arriving mid-charge: PV has dropped, the battery is covering part of the car and
123-
# the grid has swung to import. Without subtracting the battery discharge this would still read
124-
# 4.0kW and an automation would happily keep the car charging out of the battery.
125+
# Cloud cover mid-charge: the battery is covering part of the car and the grid has swung
126+
# to import. Without the subtraction this reads 4.0kW and the car keeps charging off it.
125127
print("Test: battery discharge is not offered to the car as solar surplus")
126128
failed |= check_surplus(my_predbat, "battery covering the car", grid_power=-3000, battery_power=3000, car_charging_power=7000, car_configured=True, expect_state=1.0)
127129

128-
print("Test: battery charging is left in the surplus, as who gets it is the user's choice")
130+
print("Test: battery charging takes the surplus, it is not added back")
129131
failed |= check_surplus(my_predbat, "battery charging", grid_power=1000, battery_power=-2000, car_charging_power=0, car_configured=False, expect_state=1.0)
130132

133+
# Outside the CT clamp the grid reading never saw the car, so the add-back would invent a
134+
# surplus equal to the charger draw and the automation would never turn the car off.
135+
print("Test: a charger outside the CT clamp is not added back")
136+
failed |= check_surplus(my_predbat, "outside CT clamp", grid_power=0, battery_power=0, car_charging_power=7000, car_configured=True, expect_state=0.0, car_reported_load=False)
137+
138+
print("Test: inside the CT clamp the same reading does add the car back")
139+
failed |= check_surplus(my_predbat, "inside CT clamp", grid_power=0, battery_power=0, car_charging_power=7000, car_configured=True, expect_state=7.0, car_reported_load=True)
140+
141+
# A grid sensor wired positive-on-import without grid_power_invert reads +8kW while the
142+
# house imports 8kW. Unclamped the surplus would track the import.
143+
print("Test: the surplus can never exceed generation")
144+
failed |= check_surplus(my_predbat, "inverted grid sensor at night", grid_power=8000, battery_power=0, car_charging_power=0, car_configured=False, expect_state=0.0, pv_power=0)
145+
failed |= check_surplus(my_predbat, "clamped to PV", grid_power=8000, battery_power=0, car_charging_power=0, car_configured=False, expect_state=3.0, pv_power=3000)
146+
147+
print("Test: a genuine surplus is not clipped by the clamp")
148+
failed |= check_surplus(my_predbat, "surplus below PV", grid_power=2500, battery_power=0, car_charging_power=0, car_configured=False, expect_state=2.5, pv_power=4000)
149+
131150
print("Test: the components and whether a charger is configured are published as attributes")
132151
my_predbat.grid_power = 500
133152
my_predbat.battery_power = -250
153+
my_predbat.pv_power = 2750
134154
my_predbat.car_charging_power = 1500
135155
my_predbat.car_charging_power_configured = True
156+
my_predbat.car_energy_reported_load = True
136157
my_predbat.publish_inverter_data()
137158
item = my_predbat.ha_interface.dummy_items.get(my_predbat.prefix + SURPLUS_ENTITY)
138159
expect = {
@@ -144,8 +165,10 @@ def run_solar_surplus_tests(my_predbat):
144165
"icon": "mdi:solar-power",
145166
"grid_power": 0.5,
146167
"battery_power": -0.25,
168+
"pv_power": 2.75,
147169
"car_charging_power": 1.5,
148170
"car_charging_power_configured": True,
171+
"car_charging_power_included": True,
149172
}
150173
for key, value in expect.items():
151174
if item.get(key, None) != value:
@@ -164,6 +187,14 @@ def run_solar_surplus_tests(my_predbat):
164187
elif item.get("car_charging_power_configured", None) is not False:
165188
print("ERROR: {} car_charging_power_configured is {} expected False".format(my_predbat.prefix + SURPLUS_ENTITY, item.get("car_charging_power_configured", None)))
166189
failed = True
190+
191+
print("Test: car_charging_power_included reports whether the add-back was applied")
192+
my_predbat.car_energy_reported_load = False
193+
my_predbat.publish_inverter_data()
194+
item = my_predbat.ha_interface.dummy_items.get(my_predbat.prefix + SURPLUS_ENTITY)
195+
if item.get("car_charging_power_included", None) is not False:
196+
print("ERROR: {} car_charging_power_included is {} expected False".format(my_predbat.prefix + SURPLUS_ENTITY, item.get("car_charging_power_included", None)))
197+
failed = True
167198
finally:
168199
for key, value in saved.items():
169200
setattr(my_predbat, key, value)

0 commit comments

Comments
 (0)