From 99ddb7f9215238f21af32d831106a8dc0656b61f Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 25 Aug 2026 17:27:15 +0000 Subject: [PATCH 1/7] Publish the current solar surplus as a sensor predbat.solar_surplus_power reports the power available for a flexible load right now, aggregated over every inverter, so an automation can run a car charger, an immersion heater or anything else on spare solar without Predbat deciding anything. A car charger sits behind the grid meter, so once a car is charging the export collapses towards zero. car_charging_power is added back to recover what the export would be with the car off, which keeps the reading stable enough to drive a charger from - any other load can subtract the car_charging_power attribute if it wants the car's share left out. Battery discharge is subtracted so battery power is never offered as if it were solar: if cloud cover arrives mid-charge the sensor falls to the real surplus instead of reporting one the battery is paying for. The components are published as attributes so users can build their own variant, including a battery-versus-load priority, which is policy rather than something Predbat should pick. --- apps/predbat/execute.py | 28 ++++++ apps/predbat/tests/test_solar_surplus.py | 104 +++++++++++++++++++++++ apps/predbat/unit_test.py | 2 + 3 files changed, 134 insertions(+) create mode 100644 apps/predbat/tests/test_solar_surplus.py diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 2076b2499..e306efe6c 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -1531,6 +1531,34 @@ def publish_inverter_data(self): }, ) + # Power a flexible load could take right now without importing or draining the battery. + # + # A car charger sits behind the grid meter, so once a car is charging the export collapses + # towards zero - car_charging_power is added back to recover what the export would be with + # the car off, which is what makes this stable enough to drive a charger from. Any other + # load reading this while a car charges wants the car's share left out, so it can subtract + # the car_charging_power attribute. Battery discharge is subtracted so battery power is + # never offered up as if it were solar: if cloud cover arrives mid-charge the battery starts + # covering the load, and without the subtraction the sensor would keep reporting a surplus + # that is really coming out of the battery. Battery charging is deliberately not added - + # what gets the surplus is the user's policy, and the components are in the attributes. + solar_surplus = max(0.0, self.grid_power + self.car_charging_power - max(0.0, self.battery_power)) + self.dashboard_item( + self.prefix + ".solar_surplus_power", + state=dp3(solar_surplus / 1000.0), + attributes={ + "friendly_name": "Current solar surplus power", + "state_class": "measurement", + "unit_of_measurement": "kW", + "device_class": "power", + "icon": "mdi:solar-power", + "grid_power": dp3(self.grid_power / 1000.0), + "battery_power": dp3(self.battery_power / 1000.0), + "car_charging_power": dp3(self.car_charging_power / 1000.0), + "car_charging_power_configured": self.car_charging_power_configured, + }, + ) + def publish_inverter_config(self): """ Publish the static configuration the prediction runs from, aggregated over all the inverters diff --git a/apps/predbat/tests/test_solar_surplus.py b/apps/predbat/tests/test_solar_surplus.py new file mode 100644 index 000000000..e841de7e5 --- /dev/null +++ b/apps/predbat/tests/test_solar_surplus.py @@ -0,0 +1,104 @@ +# ----------------------------------------------------------------------------- +# 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 tests.test_infra import reset_inverter + +SURPLUS_ENTITY = ".solar_surplus_power" + + +def check_surplus(my_predbat, name, grid_power, battery_power, car_charging_power, car_configured, expect_state): + """Publish the inverter sensors for one power reading and check the surplus state""" + my_predbat.grid_power = grid_power + my_predbat.battery_power = battery_power + my_predbat.car_charging_power = car_charging_power + my_predbat.car_charging_power_configured = car_configured + my_predbat.publish_inverter_data() + + entity_id = my_predbat.prefix + SURPLUS_ENTITY + item = my_predbat.ha_interface.dummy_items.get(entity_id) + if item is None: + print("ERROR: {} was not published for {}".format(entity_id, name)) + return True + if item.get("state", None) != expect_state: + print("ERROR: {} state is {} expected {} for {}".format(entity_id, item.get("state", None), expect_state, name)) + return True + return False + + +def run_solar_surplus_tests(my_predbat): + """ + predbat.solar_surplus_power reports the power a flexible load could take right now without + importing or draining the battery, so a Home Assistant automation can start a car, an immersion + heater or anything else on spare solar without Predbat deciding anything. + """ + reset_inverter(my_predbat) + failed = False + + saved = {key: getattr(my_predbat, key) for key in ["grid_power", "battery_power", "car_charging_power", "car_charging_power_configured"]} + try: + print("Test: with no car charging the surplus is the grid export") + failed |= check_surplus(my_predbat, "plain export", grid_power=2000, battery_power=0, car_charging_power=0, car_configured=False, expect_state=2.0) + + print("Test: importing from the grid reports no surplus rather than a negative one") + failed |= check_surplus(my_predbat, "importing", grid_power=-1500, battery_power=0, car_charging_power=0, car_configured=False, expect_state=0.0) + + print("Test: a charging car is added back, so the sensor does not collapse once the car starts") + failed |= check_surplus(my_predbat, "car charging", grid_power=200, battery_power=0, car_charging_power=7000, car_configured=True, expect_state=7.2) + + # Cloud cover arriving mid-charge: PV has dropped, the battery is covering part of the car and + # the grid has swung to import. Without subtracting the battery discharge this would still read + # 4.0kW and an automation would happily keep the car charging out of the battery. + print("Test: battery discharge is not offered to the car as solar surplus") + 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) + + print("Test: battery charging is left in the surplus, as who gets it is the user's choice") + failed |= check_surplus(my_predbat, "battery charging", grid_power=1000, battery_power=-2000, car_charging_power=0, car_configured=False, expect_state=1.0) + + print("Test: the components and whether a charger is configured are published as attributes") + my_predbat.grid_power = 500 + my_predbat.battery_power = -250 + my_predbat.car_charging_power = 1500 + my_predbat.car_charging_power_configured = True + my_predbat.publish_inverter_data() + item = my_predbat.ha_interface.dummy_items.get(my_predbat.prefix + SURPLUS_ENTITY) + expect = { + "state": 2.0, + "friendly_name": "Current solar surplus power", + "state_class": "measurement", + "unit_of_measurement": "kW", + "device_class": "power", + "icon": "mdi:solar-power", + "grid_power": 0.5, + "battery_power": -0.25, + "car_charging_power": 1.5, + "car_charging_power_configured": True, + } + for key, value in expect.items(): + if item.get(key, None) != value: + print("ERROR: {} {} is {} expected {}".format(my_predbat.prefix + SURPLUS_ENTITY, key, item.get(key, None), value)) + failed = True + + print("Test: the sensor is published even when no car charger is configured") + my_predbat.car_charging_power = 0 + my_predbat.car_charging_power_configured = False + my_predbat.ha_interface.dummy_items.pop(my_predbat.prefix + SURPLUS_ENTITY, None) + my_predbat.publish_inverter_data() + item = my_predbat.ha_interface.dummy_items.get(my_predbat.prefix + SURPLUS_ENTITY) + if item is None: + print("ERROR: {} was not published with no car charger configured".format(my_predbat.prefix + SURPLUS_ENTITY)) + failed = True + elif item.get("car_charging_power_configured", None) is not False: + print("ERROR: {} car_charging_power_configured is {} expected False".format(my_predbat.prefix + SURPLUS_ENTITY, item.get("car_charging_power_configured", None))) + failed = True + finally: + for key, value in saved.items(): + setattr(my_predbat, key, value) + + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index f076cb42d..af7f4feea 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -32,6 +32,7 @@ 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_solar_surplus import run_solar_surplus_tests 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 @@ -443,6 +444,7 @@ def main(): ("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), + ("solar_surplus", run_solar_surplus_tests, "Solar surplus power sensor tests", 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), From b3808a460ee77fe57499b1d4f9585d9ba783e8ca Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 25 Aug 2026 17:28:14 +0000 Subject: [PATCH 2/7] Report an active force export slot as a binary sensor binary_sensor.predbat_force_export_slot says whether the plan has a force export window running now, so an automation charging a car on solar surplus can leave the solar for Predbat to sell instead. It is taken from the plan rather than from the commanded state behind binary_sensor.predbat_exporting, which is off in read only mode, off during Hold exporting and only on for the minutes an export is actually being commanded. The plan-derived version stays on right through a slot and covers freeze export, where the battery is held and the PV is what gets sold. --- apps/predbat/output.py | 13 +++++ apps/predbat/tests/test_solar_surplus.py | 69 ++++++++++++++++++++++++ apps/predbat/unit_test.py | 2 +- 3 files changed, 83 insertions(+), 1 deletion(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 581000e08..0935e9f6f 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -3082,6 +3082,19 @@ def set_charge_export_status(self, isCharging, isExporting, isDemand): ) 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"}) + # Whether the plan has a force export window running now, taken from the plan rather than + # from isExporting above. That makes it stay on right through a slot, cover freeze export + # (where Predbat is selling the PV rather than the battery) and the Hold exporting phases, + # and still report in read only mode - all cases where an automation wants to leave the + # solar for Predbat to sell, but where the commanded state above is off. + export_window_n = self.in_charge_window(self.export_window_best, self.minutes_now) + in_export_slot = self.set_export_window and export_window_n >= 0 and self.export_limits_best[export_window_n] < EXPORT_LIMIT_IDLE + self.dashboard_item( + "binary_sensor." + self.prefix + "_force_export_slot", + state="on" if in_export_slot else "off", + attributes={"friendly_name": "Predbat is in a force export slot", "icon": "mdi:transmission-tower-export"}, + ) + def yesterday_reconstruct_car_slots(self, end_record, yesterday_load_step, minutes_now): """Rebuild car charging slots for yesterday and today-so-far, and subtract them from the load band. diff --git a/apps/predbat/tests/test_solar_surplus.py b/apps/predbat/tests/test_solar_surplus.py index e841de7e5..79453d865 100644 --- a/apps/predbat/tests/test_solar_surplus.py +++ b/apps/predbat/tests/test_solar_surplus.py @@ -8,9 +8,11 @@ # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init +from const import EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE from tests.test_infra import reset_inverter SURPLUS_ENTITY = ".solar_surplus_power" +EXPORT_SLOT_ENTITY = "_force_export_slot" def check_surplus(my_predbat, name, grid_power, battery_power, car_charging_power, car_configured, expect_state): @@ -32,6 +34,71 @@ def check_surplus(my_predbat, name, grid_power, battery_power, car_charging_powe return False +def check_export_slot(my_predbat, name, minutes_now, export_window, export_limits, set_export_window, expect_on): + """Publish the charge/export status for one plan state and check the force export slot sensor""" + my_predbat.minutes_now = minutes_now + my_predbat.export_window_best = export_window + my_predbat.export_limits_best = export_limits + my_predbat.set_export_window = set_export_window + my_predbat.set_charge_export_status(False, False, True) + + entity_id = "binary_sensor." + my_predbat.prefix + EXPORT_SLOT_ENTITY + item = my_predbat.ha_interface.dummy_items.get(entity_id) + if item is None: + print("ERROR: {} was not published for {}".format(entity_id, name)) + return True + expect_state = "on" if expect_on else "off" + if item.get("state", None) != expect_state: + print("ERROR: {} state is {} expected {} for {}".format(entity_id, item.get("state", None), expect_state, name)) + return True + return False + + +def run_export_slot_tests(my_predbat): + """ + binary_sensor.predbat_force_export_slot reports whether the plan has a force export window + running now, so an automation can leave the solar for Predbat to sell rather than putting it + into the car. + """ + failed = False + saved = {key: getattr(my_predbat, key) for key in ["minutes_now", "export_window_best", "export_limits_best", "set_export_window"]} + window = [{"start": 600, "end": 660}] + + try: + print("Test: inside a force export window the sensor is on") + failed |= check_export_slot(my_predbat, "inside window", 630, window, [10.0], True, True) + + print("Test: a freeze export window counts, as Predbat is selling the solar") + failed |= check_export_slot(my_predbat, "freeze export", 630, window, [EXPORT_LIMIT_FREEZE], True, True) + + print("Test: a window left at the idle limit is not an export slot") + failed |= check_export_slot(my_predbat, "idle limit", 630, window, [EXPORT_LIMIT_IDLE], True, False) + + print("Test: before and after the window the sensor is off") + failed |= check_export_slot(my_predbat, "before window", 599, window, [10.0], True, False) + failed |= check_export_slot(my_predbat, "after window", 660, window, [10.0], True, False) + + print("Test: with export control disabled the sensor is off even inside a window") + failed |= check_export_slot(my_predbat, "export control off", 630, window, [10.0], False, False) + + print("Test: with no windows planned the sensor is off") + failed |= check_export_slot(my_predbat, "no windows", 630, [], [], True, False) + + print("Test: the charging/exporting/demand sensors still report the commanded state") + my_predbat.set_charge_export_status(True, False, False) + for entity, expect_state in [("_charging", "on"), ("_exporting", "off"), ("_demand", "off")]: + entity_id = "binary_sensor." + my_predbat.prefix + entity + item = my_predbat.ha_interface.dummy_items.get(entity_id) + if item is None or item.get("state", None) != expect_state: + print("ERROR: {} state is {} expected {}".format(entity_id, item.get("state", None) if item else None, expect_state)) + failed = True + finally: + for key, value in saved.items(): + setattr(my_predbat, key, value) + + return failed + + def run_solar_surplus_tests(my_predbat): """ predbat.solar_surplus_power reports the power a flexible load could take right now without @@ -101,4 +168,6 @@ def run_solar_surplus_tests(my_predbat): for key, value in saved.items(): setattr(my_predbat, key, value) + failed |= run_export_slot_tests(my_predbat) + return failed diff --git a/apps/predbat/unit_test.py b/apps/predbat/unit_test.py index af7f4feea..7a26628ab 100644 --- a/apps/predbat/unit_test.py +++ b/apps/predbat/unit_test.py @@ -444,7 +444,7 @@ def main(): ("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), - ("solar_surplus", run_solar_surplus_tests, "Solar surplus power sensor tests", False), + ("solar_surplus", run_solar_surplus_tests, "Solar surplus power and force export slot sensor tests", 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), From de0993a3790c722929b9ae350bb860398c0b954c Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 25 Aug 2026 17:28:36 +0000 Subject: [PATCH 3/7] Correct the units on the current power sensors in the docs predbat.load_power, battery_power, pv_power and grid_power are published in kW (publish_inverter_data divides by 1000 and sets unit_of_measurement kW) but the documentation called them Watts. Record the sign conventions while here. --- docs/output-data.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/output-data.md b/docs/output-data.md index c53d39d0b..4bcfc90cc 100644 --- a/docs/output-data.md +++ b/docs/output-data.md @@ -283,10 +283,10 @@ predbat.status additionally has the following attributes that are automatically Predbat outputs the values it read from your inverters as totals, this gives the current power flow: -- predbat.load_power - The current house load in Watts -- predbat.battery_power - The current power of your battery (charging or discharging) in Watts -- predbat.pv_power - The current power of your PV system in Watts -- predbat.grid_power - The current grid power flow (import or export) in Watts +- predbat.load_power - The current house load in kW +- predbat.battery_power - The current power of your battery in kW, positive when discharging and negative when charging +- predbat.pv_power - The current power of your PV system in kW +- predbat.grid_power - The current grid power flow in kW, positive when exporting and negative when importing - predbat.car_charging_power - The current power drawn by your car charger in kW. Only published when **car_charging_power** is set in `apps.yaml`, or wired up automatically by a supported charger integration (Ohme, myenergi Zappi, GivEnergy EV charger, AlphaESS or the Predbat gateway) - see [car charging](car-charging.md#configure-appsyaml-for-your-car-charging) ## Baseline data From bce4fe99a4a370743e76b52b0624e1f27d700573 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 25 Aug 2026 17:33:29 +0000 Subject: [PATCH 4/7] Document the solar surplus and force export slot sensors Adds a 'Charging from solar surplus' section to the car charging docs covering the formula, the attributes, an example automation with on/off thresholds and a proportional-amps variant, and a table separating live surplus from the predicted export triggers and the commanded exporting sensor. --- docs/car-charging.md | 100 +++++++++++++++++++++++++++++++++++++++++++ docs/output-data.md | 4 ++ 2 files changed, 104 insertions(+) diff --git a/docs/car-charging.md b/docs/car-charging.md index 8cabe4852..c0969db36 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -716,6 +716,106 @@ mode: single Note: [Multiple cars](car-charging.md#multiple-electric-cars) can be planned with Predbat. +## Charging from solar surplus + +Predbat plans car charging from energy rates, so it will not start your car just because the sun came out. +Two sensors are published so you can do that yourself in a Home Assistant automation, or hand the decision to a charger that has its own solar mode: + +- **predbat.solar_surplus_power** - the power in kW that a flexible load could take right now without importing or draining the battery, totalled over all of your inverters. Nothing about it is car-specific, so an immersion heater or any other load you can divert to can use it just as well +- **binary_sensor.predbat_force_export_slot** - 'on' when the plan has a force export slot running now, so the solar is worth more sold than put in the car + +The surplus is calculated as: + +```text +max(0, grid_power + car_charging_power - max(0, battery_power)) +``` + +Your charger sits behind the grid meter, so as soon as the car starts charging the export collapses towards zero. +**car_charging_power** is added back so the sensor keeps reporting what is available for the car rather than dropping to nothing the moment you act on it. +Battery discharge is subtracted so battery power is never reported as solar: if a cloud arrives mid-charge, the sensor falls to the real surplus and your automation turns the car back down instead of quietly emptying the battery. +Battery *charging* power is left in the surplus, as whether the car or the battery gets the spare solar is your choice - the components are published as attributes (**grid_power**, **battery_power**, **car_charging_power** and **car_charging_power_configured**) if you want to decide differently. + +The sensor is always published. With no **car_charging_power** configured it is just the grid export clamped at zero, and **car_charging_power_configured** is False to tell you the add-back is not in play. +Set **car_charging_power** in `apps.yaml` for the sensor to hold up while the car is charging - see [configure apps.yaml for your car charging](#configure-appsyaml-for-your-car-charging). +It must be a sensor for the charger at your house. A vehicle-side sensor from the car maker's cloud reports charging away from home too, which would show as a surplus that is not there. + +If you are diverting to something other than the car, subtract the **car_charging_power** attribute from the state to leave the car's share out: +`states('predbat.solar_surplus_power') | float(0) - (state_attr('predbat.solar_surplus_power', 'car_charging_power') | float(0))`. + +### Example automation + +Start the car when there has been a sustained surplus above 1.4kW (6A at 230V, the minimum most chargers will take) and Predbat is not force exporting, or when Predbat has planned a cheap rate charging slot: + +```yaml +alias: Car charging on solar surplus +triggers: + - trigger: numeric_state + entity_id: predbat.solar_surplus_power + above: 1.4 + for: "00:05:00" + id: surplus + - trigger: numeric_state + entity_id: predbat.solar_surplus_power + below: 1.2 + for: "00:05:00" + id: no_surplus + - trigger: state + entity_id: binary_sensor.predbat_car_charging_slot + id: planned + - trigger: state + entity_id: binary_sensor.predbat_force_export_slot + to: "on" + id: exporting +actions: + - choose: + - conditions: + - condition: or + conditions: + - condition: trigger + id: planned + # Predbat planned this slot, so charge regardless of the sun + - condition: and + conditions: + - condition: trigger + id: surplus + - condition: state + entity_id: binary_sensor.predbat_force_export_slot + state: "off" + sequence: + + default: + +mode: single +``` + +The 1.4kW on and 1.2kW off thresholds give the charger something to sit in, so passing cloud does not switch it on and off repeatedly. +Use the `for:` duration to suit how fast your inverter reports - five minutes is a reasonable start. + +If your charger can vary its current, set the amps from the surplus rather than switching it on and off: + +```yaml + - action: number.set_value + target: + entity_id: number.my_charger_current + data: + value: > + {{ [[(states('predbat.solar_surplus_power') | float(0) * 1000 / 230) + | round(0), 6] | max, 32] | min }} +``` + +A charger with its own solar mode (Zappi Eco+, Hypervolt Super Eco, Ohme) does not need any of this - use the sensors to switch the charger into and out of that mode, and leave the current modulation to the charger. + +### Which sensor to use + +Four entities look similar and are easy to mix up: + +| Entity | What it tells you | +| --- | --- | +| **predbat.solar_surplus_power** | Spare solar *right now*, updated roughly every minute | +| **binary_sensor.predbat_export_trigger_NAME** | Export *predicted* in the plan for a future slot - see [Triggers in apps.yaml](apps-yaml.md#triggers) | +| **binary_sensor.predbat_force_export_slot** | A force export slot in the plan is running now, including freeze export slots where only the solar is being sold | +| **binary_sensor.predbat_exporting** | Predbat is *commanding* the inverter to export at this moment. Off in read-only mode and while exporting is being held, so it is not a reliable way to tell that a slot is running | + ## Additional Car charging configurations - **switch.predbat_car_charging_from_battery** - When set to On the car can drain the home battery, Predbat will manage the correct level of battery accordingly. diff --git a/docs/output-data.md b/docs/output-data.md index 4bcfc90cc..512c4b18b 100644 --- a/docs/output-data.md +++ b/docs/output-data.md @@ -287,6 +287,7 @@ Predbat outputs the values it read from your inverters as totals, this gives the - predbat.battery_power - The current power of your battery in kW, positive when discharging and negative when charging - predbat.pv_power - The current power of your PV system in kW - predbat.grid_power - The current grid power flow in kW, positive when exporting and negative when importing +- predbat.solar_surplus_power - The power in kW a flexible load (a car charger, an immersion heater or anything else you can divert to) could take right now without importing or draining the battery. Attributes give the **grid_power**, **battery_power** and **car_charging_power** it was calculated from, and **car_charging_power_configured** - see [charging from solar surplus](car-charging.md#charging-from-solar-surplus) - predbat.car_charging_power - The current power drawn by your car charger in kW. Only published when **car_charging_power** is set in `apps.yaml`, or wired up automatically by a supported charger integration (Ohme, myenergi Zappi, GivEnergy EV charger, AlphaESS or the Predbat gateway) - see [car charging](car-charging.md#configure-appsyaml-for-your-car-charging) ## Baseline data @@ -424,6 +425,9 @@ The following sensors are set based on what Predbat is currently controlling the - binary_sensor.predbat_charging - Set to 'on' when Predbat is force charging the battery (from solar, or if that is insufficient, from grid import), or 'off' otherwise - binary_sensor.predbat_exporting - Set to 'on' when Predbat is force discharging the battery for export income, 'off' otherwise. +- binary_sensor.predbat_force_export_slot - Set to 'on' when the plan has a force export slot running now, 'off' otherwise. +Unlike the two sensors above this comes from the plan rather than from what is being commanded, so it stays on for the whole slot, +includes freeze export slots, and still reports when Predbat is in read-only mode - see [charging from solar surplus](car-charging.md#charging-from-solar-surplus) These are useful for automations if for example, you want to turn off car charging when the battery is being exported. From 5fca8811bf35cf24288cca15847815979724f331 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Wed, 26 Aug 2026 08:03:15 +0000 Subject: [PATCH 5/7] 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. --- apps/predbat/execute.py | 28 +++--- apps/predbat/output.py | 8 +- apps/predbat/tests/test_solar_surplus.py | 55 ++++++++--- docs/car-charging.md | 119 +++++++++++++++++------ docs/output-data.md | 2 +- 5 files changed, 152 insertions(+), 60 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index e306efe6c..e9167a3b3 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -1531,18 +1531,22 @@ def publish_inverter_data(self): }, ) - # Power a flexible load could take right now without importing or draining the battery. + # Power a load could take now without importing or draining the battery. # - # A car charger sits behind the grid meter, so once a car is charging the export collapses - # towards zero - car_charging_power is added back to recover what the export would be with - # the car off, which is what makes this stable enough to drive a charger from. Any other - # load reading this while a car charges wants the car's share left out, so it can subtract - # the car_charging_power attribute. Battery discharge is subtracted so battery power is - # never offered up as if it were solar: if cloud cover arrives mid-charge the battery starts - # covering the load, and without the subtraction the sensor would keep reporting a surplus - # that is really coming out of the battery. Battery charging is deliberately not added - - # what gets the surplus is the user's policy, and the components are in the attributes. - solar_surplus = max(0.0, self.grid_power + self.car_charging_power - max(0.0, self.battery_power)) + # The car's own draw is added back so the sensor keeps reading what is available for it + # rather than collapsing once the charger starts. Only when the charger is inside the CT + # clamp: outside it the grid reading never saw the car, so adding it would invent a surplus + # equal to the charger draw. prediction.py branches on the same flag. + car_add_back = self.car_charging_power if self.car_energy_reported_load else 0.0 + + # Subtracting battery discharge keeps battery power from being reported as solar. Battery + # charging is not added back, so a charging battery takes the surplus first. + solar_surplus = max(0.0, self.grid_power + car_add_back - max(0.0, self.battery_power)) + + # Nothing can be spare that was never generated. A grid sensor wired positive-on-import + # without grid_power_invert would otherwise make the surplus track the import, and this + # sensor switches real load on. + solar_surplus = min(solar_surplus, max(0.0, self.pv_power)) self.dashboard_item( self.prefix + ".solar_surplus_power", state=dp3(solar_surplus / 1000.0), @@ -1554,8 +1558,10 @@ def publish_inverter_data(self): "icon": "mdi:solar-power", "grid_power": dp3(self.grid_power / 1000.0), "battery_power": dp3(self.battery_power / 1000.0), + "pv_power": dp3(self.pv_power / 1000.0), "car_charging_power": dp3(self.car_charging_power / 1000.0), "car_charging_power_configured": self.car_charging_power_configured, + "car_charging_power_included": bool(self.car_energy_reported_load), }, ) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 0935e9f6f..b5e72bc16 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -3082,11 +3082,9 @@ def set_charge_export_status(self, isCharging, isExporting, isDemand): ) 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"}) - # Whether the plan has a force export window running now, taken from the plan rather than - # from isExporting above. That makes it stay on right through a slot, cover freeze export - # (where Predbat is selling the PV rather than the battery) and the Hold exporting phases, - # and still report in read only mode - all cases where an automation wants to leave the - # solar for Predbat to sell, but where the commanded state above is off. + # Taken from the plan, not from isExporting above. isExporting is off in read only mode, + # off during Hold exporting, and only on for the minutes an export is commanded, whereas an + # automation needs to know a slot is running for its whole duration including freeze export. export_window_n = self.in_charge_window(self.export_window_best, self.minutes_now) in_export_slot = self.set_export_window and export_window_n >= 0 and self.export_limits_best[export_window_n] < EXPORT_LIMIT_IDLE self.dashboard_item( diff --git a/apps/predbat/tests/test_solar_surplus.py b/apps/predbat/tests/test_solar_surplus.py index 79453d865..787fc8a0a 100644 --- a/apps/predbat/tests/test_solar_surplus.py +++ b/apps/predbat/tests/test_solar_surplus.py @@ -15,12 +15,17 @@ EXPORT_SLOT_ENTITY = "_force_export_slot" -def check_surplus(my_predbat, name, grid_power, battery_power, car_charging_power, car_configured, expect_state): - """Publish the inverter sensors for one power reading and check the surplus state""" +def check_surplus(my_predbat, name, grid_power, battery_power, car_charging_power, car_configured, expect_state, pv_power=20000, car_reported_load=True): + """Publish the inverter sensors for one power reading and check the surplus state + + pv_power defaults high enough not to clip. Cases exercising the clamp pass their own. + """ my_predbat.grid_power = grid_power my_predbat.battery_power = battery_power + my_predbat.pv_power = pv_power my_predbat.car_charging_power = car_charging_power my_predbat.car_charging_power_configured = car_configured + my_predbat.car_energy_reported_load = car_reported_load my_predbat.publish_inverter_data() entity_id = my_predbat.prefix + SURPLUS_ENTITY @@ -57,8 +62,7 @@ def check_export_slot(my_predbat, name, minutes_now, export_window, export_limit def run_export_slot_tests(my_predbat): """ binary_sensor.predbat_force_export_slot reports whether the plan has a force export window - running now, so an automation can leave the solar for Predbat to sell rather than putting it - into the car. + running, so an automation can leave the solar for Predbat to sell. """ failed = False 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): def run_solar_surplus_tests(my_predbat): """ - predbat.solar_surplus_power reports the power a flexible load could take right now without - importing or draining the battery, so a Home Assistant automation can start a car, an immersion - heater or anything else on spare solar without Predbat deciding anything. + predbat.solar_surplus_power reports the power a load could take now without importing or + draining the battery, so an automation can run a car or immersion heater on spare solar. """ reset_inverter(my_predbat) failed = False - saved = {key: getattr(my_predbat, key) for key in ["grid_power", "battery_power", "car_charging_power", "car_charging_power_configured"]} + 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"]} try: print("Test: with no car charging the surplus is the grid export") 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): print("Test: a charging car is added back, so the sensor does not collapse once the car starts") failed |= check_surplus(my_predbat, "car charging", grid_power=200, battery_power=0, car_charging_power=7000, car_configured=True, expect_state=7.2) - # Cloud cover arriving mid-charge: PV has dropped, the battery is covering part of the car and - # the grid has swung to import. Without subtracting the battery discharge this would still read - # 4.0kW and an automation would happily keep the car charging out of the battery. + # Cloud cover mid-charge: the battery is covering part of the car and the grid has swung + # to import. Without the subtraction this reads 4.0kW and the car keeps charging off it. print("Test: battery discharge is not offered to the car as solar surplus") 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) - print("Test: battery charging is left in the surplus, as who gets it is the user's choice") + print("Test: battery charging takes the surplus, it is not added back") failed |= check_surplus(my_predbat, "battery charging", grid_power=1000, battery_power=-2000, car_charging_power=0, car_configured=False, expect_state=1.0) + # Outside the CT clamp the grid reading never saw the car, so the add-back would invent a + # surplus equal to the charger draw and the automation would never turn the car off. + print("Test: a charger outside the CT clamp is not added back") + 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) + + print("Test: inside the CT clamp the same reading does add the car back") + 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) + + # A grid sensor wired positive-on-import without grid_power_invert reads +8kW while the + # house imports 8kW. Unclamped the surplus would track the import. + print("Test: the surplus can never exceed generation") + 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) + 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) + + print("Test: a genuine surplus is not clipped by the clamp") + 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) + print("Test: the components and whether a charger is configured are published as attributes") my_predbat.grid_power = 500 my_predbat.battery_power = -250 + my_predbat.pv_power = 2750 my_predbat.car_charging_power = 1500 my_predbat.car_charging_power_configured = True + my_predbat.car_energy_reported_load = True my_predbat.publish_inverter_data() item = my_predbat.ha_interface.dummy_items.get(my_predbat.prefix + SURPLUS_ENTITY) expect = { @@ -144,8 +165,10 @@ def run_solar_surplus_tests(my_predbat): "icon": "mdi:solar-power", "grid_power": 0.5, "battery_power": -0.25, + "pv_power": 2.75, "car_charging_power": 1.5, "car_charging_power_configured": True, + "car_charging_power_included": True, } for key, value in expect.items(): if item.get(key, None) != value: @@ -164,6 +187,14 @@ def run_solar_surplus_tests(my_predbat): elif item.get("car_charging_power_configured", None) is not False: print("ERROR: {} car_charging_power_configured is {} expected False".format(my_predbat.prefix + SURPLUS_ENTITY, item.get("car_charging_power_configured", None))) failed = True + + print("Test: car_charging_power_included reports whether the add-back was applied") + my_predbat.car_energy_reported_load = False + my_predbat.publish_inverter_data() + item = my_predbat.ha_interface.dummy_items.get(my_predbat.prefix + SURPLUS_ENTITY) + if item.get("car_charging_power_included", None) is not False: + print("ERROR: {} car_charging_power_included is {} expected False".format(my_predbat.prefix + SURPLUS_ENTITY, item.get("car_charging_power_included", None))) + failed = True finally: for key, value in saved.items(): setattr(my_predbat, key, value) diff --git a/docs/car-charging.md b/docs/car-charging.md index c0969db36..e7625b106 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -718,29 +718,62 @@ Note: [Multiple cars](car-charging.md#multiple-electric-cars) can be planned wit ## Charging from solar surplus -Predbat plans car charging from energy rates, so it will not start your car just because the sun came out. -Two sensors are published so you can do that yourself in a Home Assistant automation, or hand the decision to a charger that has its own solar mode: +Predbat plans car charging from energy rates. It will not start your car because the sun came out. +Two sensors let you do that yourself in a Home Assistant automation, or switch a charger into its own solar mode: -- **predbat.solar_surplus_power** - the power in kW that a flexible load could take right now without importing or draining the battery, totalled over all of your inverters. Nothing about it is car-specific, so an immersion heater or any other load you can divert to can use it just as well -- **binary_sensor.predbat_force_export_slot** - 'on' when the plan has a force export slot running now, so the solar is worth more sold than put in the car +| Entity | Meaning | +| --- | --- | +| **predbat.solar_surplus_power** | kW a load could take right now without importing or draining the battery, totalled over all inverters. Nothing about it is car-specific. An immersion heater or any other load you can divert to can use it | +| **binary_sensor.predbat_force_export_slot** | 'on' when the plan has a force export slot running, so the solar is worth more sold than put in the car | -The surplus is calculated as: +The surplus is: ```text -max(0, grid_power + car_charging_power - max(0, battery_power)) +min(max(0, grid_power + car_charging_power - max(0, battery_power)), max(0, pv_power)) ``` -Your charger sits behind the grid meter, so as soon as the car starts charging the export collapses towards zero. -**car_charging_power** is added back so the sensor keeps reporting what is available for the car rather than dropping to nothing the moment you act on it. -Battery discharge is subtracted so battery power is never reported as solar: if a cloud arrives mid-charge, the sensor falls to the real surplus and your automation turns the car back down instead of quietly emptying the battery. -Battery *charging* power is left in the surplus, as whether the car or the battery gets the spare solar is your choice - the components are published as attributes (**grid_power**, **battery_power**, **car_charging_power** and **car_charging_power_configured**) if you want to decide differently. +**car_charging_power** is added back because a charger inside the CT clamp sits behind the grid meter, so export collapses to zero as soon as the car starts. +Without the add-back the sensor would drop to nothing the moment you acted on it. +The add-back only applies when **switch.predbat_car_energy_reported_load** is On. +With it Off the charger is outside the clamp, the grid reading never saw the car, and adding it would invent a surplus equal to the whole charger draw. +The **car_charging_power_included** attribute tells you which applies. + +Battery discharge is subtracted, so battery power is never reported as solar. +When cloud arrives mid-charge the sensor falls to the real surplus and your automation turns the car down instead of emptying the battery. + +Battery *charging* power is not added back, so a charging battery takes the solar first and the sensor reads what is left. +On a sunny morning with the battery filling, that is often 0.0. +To give the car priority instead, add **battery_power** back yourself. It is negative while charging. + +The result is capped at **pv_power**. Nothing can be spare that was never generated. +With correct sign conventions the arithmetic already comes to PV minus house load, so the cap never clips a real surplus. -The sensor is always published. With no **car_charging_power** configured it is just the grid export clamped at zero, and **car_charging_power_configured** is False to tell you the add-back is not in play. -Set **car_charging_power** in `apps.yaml` for the sensor to hold up while the car is charging - see [configure apps.yaml for your car charging](#configure-appsyaml-for-your-car-charging). -It must be a sensor for the charger at your house. A vehicle-side sensor from the car maker's cloud reports charging away from home too, which would show as a surplus that is not there. +The sensor is always published. With no **car_charging_power** configured it is the grid export clamped at zero, and **car_charging_power_configured** is False. +Set **car_charging_power** in `apps.yaml` to keep the reading up while the car charges. See [configure apps.yaml for your car charging](#configure-appsyaml-for-your-car-charging). +It must measure the charger at your house. A vehicle-side sensor from the car maker's cloud also reports charging away from home. + +Attributes carry the inputs: **grid_power**, **battery_power**, **pv_power**, **car_charging_power**, **car_charging_power_configured** and **car_charging_power_included**. +To divert to something other than the car, take the car's share back off, but only when it was added: + +```text +{{ states('predbat.solar_surplus_power') | float(0) + - (state_attr('predbat.solar_surplus_power', 'car_charging_power') | float(0) + if state_attr('predbat.solar_surplus_power', 'car_charging_power_included') else 0) }} +``` -If you are diverting to something other than the car, subtract the **car_charging_power** attribute from the state to leave the car's share out: -`states('predbat.solar_surplus_power') | float(0) - (state_attr('predbat.solar_surplus_power', 'car_charging_power') | float(0))`. +### Check these before driving a load from it + +A wrong reading here spends money rather than showing a wrong number. + +- **Grid sensor sign.** Watch **predbat.grid_power** while importing. It must go negative. If it goes positive, set **grid_power_invert** in `apps.yaml`. See the [FAQ](faq.md). The PV cap bounds the error and forces 0 after dark, but cannot fix the sign in daylight: importing 3kW under 6kW of sun still reads a 3kW surplus. +- **PV inventory.** The cap is only as high as the PV Predbat can see. An AC-coupled battery inverter reporting no PV, or a GivEnergy Cloud setup with a separate solar inverter, holds the sensor at 0.0 despite real export. **ge_cloud_automatic_split_pv** is off by default and excludes standalone PV. Add the missing inverters or sensors to **pv_power**. +- **One CT-clamp setting covers every charger.** **switch.predbat_car_energy_reported_load** is a single switch, so one charger inside the clamp and one outside cannot both be described. Set it Off if any charger is outside. +- **Freshness.** If Predbat stops updating, Home Assistant serves the last value forever and your charger keeps acting on it. Gate the automation on the entity age. + +```yaml + - condition: template + value_template: "{{ (now() - states.predbat.solar_surplus_power.last_updated).total_seconds() < 600 }}" +``` ### Example automation @@ -761,23 +794,31 @@ triggers: id: no_surplus - trigger: state entity_id: binary_sensor.predbat_car_charging_slot + to: "on" id: planned + - trigger: state + entity_id: binary_sensor.predbat_car_charging_slot + to: "off" - trigger: state entity_id: binary_sensor.predbat_force_export_slot to: "on" - id: exporting + - trigger: state + entity_id: binary_sensor.predbat_force_export_slot + to: "off" actions: - choose: - conditions: - condition: or conditions: - - condition: trigger - id: planned - # Predbat planned this slot, so charge regardless of the sun + # Predbat planned this slot, so charge regardless of the sun + - condition: state + entity_id: binary_sensor.predbat_car_charging_slot + state: "on" - condition: and conditions: - - condition: trigger - id: surplus + - condition: numeric_state + entity_id: predbat.solar_surplus_power + above: 1.2 - condition: state entity_id: binary_sensor.predbat_force_export_slot state: "off" @@ -788,19 +829,35 @@ actions: mode: single ``` -The 1.4kW on and 1.2kW off thresholds give the charger something to sit in, so passing cloud does not switch it on and off repeatedly. +Every trigger is paired with a `to:` state and every condition reads the entity rather than the trigger that fired. +A bare `state` trigger fires when a slot *ends* as well as when it starts, and a trigger-id condition would then match and turn the charger on at the end of the cheap slot. +Triggering on both edges of `force_export_slot` is what lets charging resume when an export slot finishes: a `numeric_state` trigger only fires when the value *crosses* its threshold, so a surplus that sat above 1.4kW throughout the export slot would never re-fire on its own. + +The 1.4kW trigger and 1.2kW condition give the charger something to sit in, so passing cloud does not switch it on and off repeatedly. Use the `for:` duration to suit how fast your inverter reports - five minutes is a reasonable start. -If your charger can vary its current, set the amps from the surplus rather than switching it on and off: +If your charger can vary its current, set the amps from the surplus rather than switching it on and off. +Below the 6A minimum there is no current to set, so the charger still has to be turned off, and the export slot still has to be respected: ```yaml - - action: number.set_value - target: - entity_id: number.my_charger_current - data: - value: > - {{ [[(states('predbat.solar_surplus_power') | float(0) * 1000 / 230) - | round(0), 6] | max, 32] | min }} + - choose: + - conditions: + - condition: numeric_state + entity_id: predbat.solar_surplus_power + above: 1.4 # 6A at 230V + - condition: state + entity_id: binary_sensor.predbat_force_export_slot + state: "off" + sequence: + - action: number.set_value + target: + entity_id: number.my_charger_current + data: + value: > + {{ [[(states('predbat.solar_surplus_power') | float(0) * 1000 / 230) + | round(0), 6] | max, 32] | min }} + default: + ``` A charger with its own solar mode (Zappi Eco+, Hypervolt Super Eco, Ohme) does not need any of this - use the sensors to switch the charger into and out of that mode, and leave the current modulation to the charger. @@ -811,7 +868,7 @@ Four entities look similar and are easy to mix up: | Entity | What it tells you | | --- | --- | -| **predbat.solar_surplus_power** | Spare solar *right now*, updated roughly every minute | +| **predbat.solar_surplus_power** | Spare solar *right now*, updated about every two minutes (`INVERTER_QUICK_UPDATE_SECONDS`), and longer while a plan is being calculated | | **binary_sensor.predbat_export_trigger_NAME** | Export *predicted* in the plan for a future slot - see [Triggers in apps.yaml](apps-yaml.md#triggers) | | **binary_sensor.predbat_force_export_slot** | A force export slot in the plan is running now, including freeze export slots where only the solar is being sold | | **binary_sensor.predbat_exporting** | Predbat is *commanding* the inverter to export at this moment. Off in read-only mode and while exporting is being held, so it is not a reliable way to tell that a slot is running | diff --git a/docs/output-data.md b/docs/output-data.md index 512c4b18b..f174359c7 100644 --- a/docs/output-data.md +++ b/docs/output-data.md @@ -287,7 +287,7 @@ Predbat outputs the values it read from your inverters as totals, this gives the - predbat.battery_power - The current power of your battery in kW, positive when discharging and negative when charging - predbat.pv_power - The current power of your PV system in kW - predbat.grid_power - The current grid power flow in kW, positive when exporting and negative when importing -- predbat.solar_surplus_power - The power in kW a flexible load (a car charger, an immersion heater or anything else you can divert to) could take right now without importing or draining the battery. Attributes give the **grid_power**, **battery_power** and **car_charging_power** it was calculated from, and **car_charging_power_configured** - see [charging from solar surplus](car-charging.md#charging-from-solar-surplus) +- predbat.solar_surplus_power - The power in kW a flexible load (a car charger, an immersion heater or anything else you can divert to) could take right now without importing or draining the battery, capped at your current PV generation. Attributes give the **grid_power**, **battery_power**, **pv_power** and **car_charging_power** it was calculated from, plus **car_charging_power_configured** and **car_charging_power_included** - see [charging from solar surplus](car-charging.md#charging-from-solar-surplus) - predbat.car_charging_power - The current power drawn by your car charger in kW. Only published when **car_charging_power** is set in `apps.yaml`, or wired up automatically by a supported charger integration (Ohme, myenergi Zappi, GivEnergy EV charger, AlphaESS or the Predbat gateway) - see [car charging](car-charging.md#configure-appsyaml-for-your-car-charging) ## Baseline data From 6c74ec52a03baa998c5675db93e6ff1ea08ff6bb Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Mon, 5 Oct 2026 10:50:14 +0200 Subject: [PATCH 6/7] =?UTF-8?q?=F0=9F=90=9B=20Read=20the=20force=20export?= =?UTF-8?q?=20slot=20through=20export=5Fmode=5Fof?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit export_limits_best now holds (mode, target, power) tuples, so comparing an entry to EXPORT_LIMIT_IDLE raised TypeError inside any export window. Use the same accessor as publish_export_limit. The tests build their limits with pack_export_limit, keep the bare-number cases for older saved plans, and restore the published entity states they change so later tests see the shared fixture untouched. --- apps/predbat/output.py | 2 +- apps/predbat/tests/test_solar_surplus.py | 22 ++++++++++++++++++---- 2 files changed, 19 insertions(+), 5 deletions(-) diff --git a/apps/predbat/output.py b/apps/predbat/output.py index b5e72bc16..13fac5cd9 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -3086,7 +3086,7 @@ def set_charge_export_status(self, isCharging, isExporting, isDemand): # off during Hold exporting, and only on for the minutes an export is commanded, whereas an # automation needs to know a slot is running for its whole duration including freeze export. export_window_n = self.in_charge_window(self.export_window_best, self.minutes_now) - in_export_slot = self.set_export_window and export_window_n >= 0 and self.export_limits_best[export_window_n] < EXPORT_LIMIT_IDLE + in_export_slot = self.set_export_window and export_window_n >= 0 and export_mode_of(self.export_limits_best[export_window_n]) != EXPORT_MODE_IDLE self.dashboard_item( "binary_sensor." + self.prefix + "_force_export_slot", state="on" if in_export_slot else "off", diff --git a/apps/predbat/tests/test_solar_surplus.py b/apps/predbat/tests/test_solar_surplus.py index 787fc8a0a..3330e221c 100644 --- a/apps/predbat/tests/test_solar_surplus.py +++ b/apps/predbat/tests/test_solar_surplus.py @@ -8,7 +8,10 @@ # pylint: disable=line-too-long # pylint: disable=attribute-defined-outside-init -from const import EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE +import copy + +from const import EXPORT_LIMIT_FREEZE, EXPORT_LIMIT_IDLE, EXPORT_MODE_TARGET, EXPORT_MODE_FREEZE, EXPORT_MODE_IDLE +from utils import pack_export_limit from tests.test_infra import reset_inverter SURPLUS_ENTITY = ".solar_surplus_power" @@ -66,17 +69,23 @@ def run_export_slot_tests(my_predbat): """ failed = False saved = {key: getattr(my_predbat, key) for key in ["minutes_now", "export_window_best", "export_limits_best", "set_export_window"]} + saved_items = copy.deepcopy(my_predbat.ha_interface.dummy_items) window = [{"start": 600, "end": 660}] try: print("Test: inside a force export window the sensor is on") - failed |= check_export_slot(my_predbat, "inside window", 630, window, [10.0], True, True) + failed |= check_export_slot(my_predbat, "inside window", 630, window, [pack_export_limit(EXPORT_MODE_TARGET, 10)], True, True) print("Test: a freeze export window counts, as Predbat is selling the solar") - failed |= check_export_slot(my_predbat, "freeze export", 630, window, [EXPORT_LIMIT_FREEZE], True, True) + failed |= check_export_slot(my_predbat, "freeze export", 630, window, [pack_export_limit(EXPORT_MODE_FREEZE)], True, True) print("Test: a window left at the idle limit is not an export slot") - failed |= check_export_slot(my_predbat, "idle limit", 630, window, [EXPORT_LIMIT_IDLE], True, False) + failed |= check_export_slot(my_predbat, "idle limit", 630, window, [pack_export_limit(EXPORT_MODE_IDLE)], True, False) + + print("Test: the bare numbers an older saved plan decodes into read the same way") + failed |= check_export_slot(my_predbat, "legacy target", 630, window, [10.0], True, True) + failed |= check_export_slot(my_predbat, "legacy freeze", 630, window, [EXPORT_LIMIT_FREEZE], True, True) + failed |= check_export_slot(my_predbat, "legacy idle", 630, window, [EXPORT_LIMIT_IDLE], True, False) print("Test: before and after the window the sensor is off") failed |= check_export_slot(my_predbat, "before window", 599, window, [10.0], True, False) @@ -99,6 +108,8 @@ def run_export_slot_tests(my_predbat): finally: for key, value in saved.items(): setattr(my_predbat, key, value) + my_predbat.ha_interface.dummy_items.clear() + my_predbat.ha_interface.dummy_items.update(saved_items) return failed @@ -112,6 +123,7 @@ def run_solar_surplus_tests(my_predbat): failed = False 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"]} + saved_items = copy.deepcopy(my_predbat.ha_interface.dummy_items) try: print("Test: with no car charging the surplus is the grid export") failed |= check_surplus(my_predbat, "plain export", grid_power=2000, battery_power=0, car_charging_power=0, car_configured=False, expect_state=2.0) @@ -198,6 +210,8 @@ def run_solar_surplus_tests(my_predbat): finally: for key, value in saved.items(): setattr(my_predbat, key, value) + my_predbat.ha_interface.dummy_items.clear() + my_predbat.ha_interface.dummy_items.update(saved_items) failed |= run_export_slot_tests(my_predbat) From 509c40b036c71d54c2f341b55f361440e3df4abc Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Mon, 5 Oct 2026 10:50:14 +0200 Subject: [PATCH 7/7] =?UTF-8?q?=F0=9F=93=9D=20Correct=20the=20car=20priori?= =?UTF-8?q?ty=20recipe=20and=20the=20surplus=20refresh=20rate?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit battery_power is negative while charging, so giving the car priority means subtracting it, not adding it. INVERTER_QUICK_UPDATE_SECONDS is 60 seconds, not two minutes. --- docs/car-charging.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/docs/car-charging.md b/docs/car-charging.md index e7625b106..1ebf1ae4e 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -743,7 +743,12 @@ When cloud arrives mid-charge the sensor falls to the real surplus and your auto Battery *charging* power is not added back, so a charging battery takes the solar first and the sensor reads what is left. On a sunny morning with the battery filling, that is often 0.0. -To give the car priority instead, add **battery_power** back yourself. It is negative while charging. +To give the car priority instead, subtract the battery's charging power yourself. **battery_power** is negative while charging, so subtracting it raises the figure: + +```text +{{ states('predbat.solar_surplus_power') | float(0) + - ([state_attr('predbat.solar_surplus_power', 'battery_power') | float(0), 0] | min) }} +``` The result is capped at **pv_power**. Nothing can be spare that was never generated. With correct sign conventions the arithmetic already comes to PV minus house load, so the cap never clips a real surplus. @@ -868,7 +873,7 @@ Four entities look similar and are easy to mix up: | Entity | What it tells you | | --- | --- | -| **predbat.solar_surplus_power** | Spare solar *right now*, updated about every two minutes (`INVERTER_QUICK_UPDATE_SECONDS`), and longer while a plan is being calculated | +| **predbat.solar_surplus_power** | Spare solar *right now*, updated about every minute (`INVERTER_QUICK_UPDATE_SECONDS`), and longer while a plan is being calculated | | **binary_sensor.predbat_export_trigger_NAME** | Export *predicted* in the plan for a future slot - see [Triggers in apps.yaml](apps-yaml.md#triggers) | | **binary_sensor.predbat_force_export_slot** | A force export slot in the plan is running now, including freeze export slots where only the solar is being sold | | **binary_sensor.predbat_exporting** | Predbat is *commanding* the inverter to export at this moment. Off in read-only mode and while exporting is being held, so it is not a reliable way to tell that a slot is running |