From 3b48ec5b4d0a437be9107a09ed054eaa83dfaaf6 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:36:46 +0200 Subject: [PATCH 01/24] Add config items for solar surplus car charging --- apps/predbat/config.py | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index e2eda8005..4765507cb 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -749,6 +749,33 @@ "enable": "num_cars", "enable_condition": "num_cars > 0", }, + { + "name": "car_charging_solar_surplus", + "friendly_name": "Car charging on solar surplus", + "type": "switch", + "default": False, + "enable": "num_cars", + "enable_condition": "num_cars > 0", + }, + { + "name": "car_charging_solar_surplus_threshold", + "friendly_name": "Car charging solar surplus shortfall allowance", + "type": "input_number", + "min": 0, + "max": 5000, + "step": 100, + "unit": "W", + "icon": "mdi:ev-station", + "default": 500, + "enable": "car_charging_solar_surplus", + }, + { + "name": "car_charging_solar_surplus_ignore_limit", + "friendly_name": "Car charging solar surplus ignore charge limit", + "type": "switch", + "default": True, + "enable": "car_charging_solar_surplus", + }, { "name": "calculate_export_oncharge", "oldname": "calculate_discharge_oncharge", From bd75adea6aa84a0e4fc7d60fb9f537acf189c435 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:38:00 +0200 Subject: [PATCH 02/24] Initialise solar surplus car charging state --- apps/predbat/predbat.py | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index fec4faa30..aa3a52438 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -600,6 +600,7 @@ def reset(self): self.charge_rate_now = 0 self.discharge_rate_now = 0 self.car_charging_hold = False + self.car_charging_solar_surplus_active = [] self.car_charging_manual_soc = [] self.car_charging_threshold = 99 self.car_charging_energy = {} From f515a42efd6ae820fe99f2556ae2c71ad29aca25 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:38:08 +0200 Subject: [PATCH 03/24] Read solar surplus car charging config options --- apps/predbat/fetch.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 5e8c92595..cd4728edd 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -2346,6 +2346,9 @@ def fetch_config_options(self): self.car_charging_manual_soc[car_n] = self.get_arg("car_charging_manual_soc" + car_postfix, False) self.car_charging_threshold = float(self.get_arg("car_charging_threshold")) / 60.0 self.car_charging_energy_scale = self.get_arg("car_charging_energy_scale") + self.car_charging_solar_surplus = self.get_arg("car_charging_solar_surplus") + self.car_charging_solar_surplus_threshold = float(self.get_arg("car_charging_solar_surplus_threshold")) + self.car_charging_solar_surplus_ignore_limit = self.get_arg("car_charging_solar_surplus_ignore_limit") # Update list of slot times self.manual_charge_times = self.manual_times("manual_charge") From f17615b134906408ea365728923c73842e1d0593 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:39:17 +0200 Subject: [PATCH 04/24] Add solar surplus car charging detection and battery protection in execute_plan --- apps/predbat/execute.py | 125 +++++++++++++++++++++++++++++++++------- 1 file changed, 103 insertions(+), 22 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 06de458ef..a7c3543b8 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -427,38 +427,82 @@ def execute_plan(self): status_freeze_export = " [Freeze exporting]" + # Solar surplus car charging - detect excess solar export and activate car charging + self.car_charging_solar_surplus_active = [False] * self.num_cars + if self.car_charging_solar_surplus and self.num_cars > 0 and not isExporting: + surplus_hysteresis = 200 # W deadband to prevent flapping + was_active = getattr(self, "_car_surplus_prev", [False] * self.num_cars) + for car_n in range(self.num_cars): + if not self.car_charging_planned[car_n]: + continue + if not self.car_charging_solar_surplus_ignore_limit and self.car_charging_soc[car_n] >= self.car_charging_limit[car_n]: + continue + + car_rate_w = self.car_charging_rate[car_n] * 1000 + threshold = self.car_charging_solar_surplus_threshold + + # When car was surplus-charging last cycle, add back its load to get true available export + effective_export = self.grid_power + previously_active = car_n < len(was_active) and was_active[car_n] + if previously_active: + effective_export += car_rate_w + + if previously_active: + # Currently on: lower bar to stay on, no battery check needed + if effective_export >= car_rate_w - threshold - surplus_hysteresis: + self.car_charging_solar_surplus_active[car_n] = True + else: + # Currently off: higher bar to turn on, require battery not discharging + if effective_export >= car_rate_w - threshold + surplus_hysteresis and self.battery_power <= surplus_hysteresis: + self.car_charging_solar_surplus_active[car_n] = True + + if self.car_charging_solar_surplus_active[car_n]: + self.log( + "Solar surplus car charging active for car {}: export {}W (effective {}W), rate {}W, threshold {}W".format( + car_n, int(self.grid_power), int(effective_export), int(car_rate_w), int(threshold) + ) + ) + break # One car at a time from surplus + self._car_surplus_prev = list(self.car_charging_solar_surplus_active) + # Car charging from battery disable? carHolding = False if self.set_charge_window and not self.car_charging_from_battery and self.car_energy_reported_load: for car_n in range(self.num_cars): + surplus_active = car_n < len(self.car_charging_solar_surplus_active) and self.car_charging_solar_surplus_active[car_n] + in_planned_slot = False if self.car_charging_slots[car_n]: window = self.car_charging_slots[car_n][0] if self.car_charging_soc[car_n] >= self.car_charging_limit[car_n]: self.log("Car {} is already charged, ignoring additional charging slot from {} - {}".format(car_n, self.time_abs_str(window["start"]), self.time_abs_str(window["end"]))) elif self.minutes_now >= window["start"] and self.minutes_now < window["end"] and window.get("kwh", 0) > 0: - self.log("Car charging from battery is off, next slot for car {} is {} - {}".format(car_n, self.time_abs_str(window["start"]), self.time_abs_str(window["end"]))) - # Don't disable discharge during force charge/discharge slots but otherwise turn it off to prevent - # from draining the battery - if not isExporting: - if inverter.inv_has_timed_pause: - if resetPause: - inverter.adjust_pause_mode(pause_discharge=True) - resetPause = False + in_planned_slot = True + if surplus_active or in_planned_slot: + slot_type = "solar surplus" if surplus_active and not in_planned_slot else "planned slot" + self.log("Car charging from battery is off, car {} active via {} ".format(car_n, slot_type)) + # Don't disable discharge during force charge/discharge slots but otherwise turn it off to prevent + # from draining the battery + if not isExporting: + if inverter.inv_has_timed_pause: + if resetPause: + inverter.adjust_pause_mode(pause_discharge=True) + resetPause = False + else: + if resetDischarge: + inverter.adjust_discharge_rate(0) + resetDischarge = False + if self.set_reserve_enable: + inverter.adjust_reserve(min(inverter.soc_percent + 1, 100)) + resetReserve = False + carHolding = True + self.log("Disabling battery discharge whilst car {} is charging".format(car_n)) + hold_label = "Hold for car (solar)" if surplus_active and not in_planned_slot else "Hold for car" + if ("Hold for car" not in status) and (status_hold_car == ""): + if status == "Demand": + status = hold_label else: - if resetDischarge: - inverter.adjust_discharge_rate(0) - resetDischarge = False - if self.set_reserve_enable: - inverter.adjust_reserve(min(inverter.soc_percent + 1, 100)) - resetReserve = False - carHolding = True - self.log("Disabling battery discharge whilst car {} is charging".format(car_n)) - if ("Hold for car" not in status) and (status_hold_car == ""): - if status == "Demand": - status = "Hold for car" - else: - status_hold_car = ", Hold for car" - break + status_hold_car = ", " + hold_label + break # iBoost running? boostHolding = False @@ -610,6 +654,43 @@ def execute_plan(self): self.count_inverter_writes[inverter.id] += inverter.count_register_writes inverter.count_register_writes = 0 + # Publish solar surplus car charging binary sensor overrides + for car_n in range(self.num_cars): + if not self.car_charging_solar_surplus_active[car_n]: + continue + # Check if a planned slot is already active (no need to override) + in_planned_slot = False + if self.car_charging_slots[car_n]: + window = self.car_charging_slots[car_n][0] + if self.minutes_now >= window["start"] and self.minutes_now < window["end"] and window.get("kwh", 0) > 0: + in_planned_slot = True + if not in_planned_slot: + postfix = "" if car_n == 0 else "_" + str(car_n) + self.dashboard_item( + "binary_sensor." + self.prefix + "_car_charging_slot" + postfix, + state="on", + attributes={ + "planned": "solar_surplus", + "cost": 0, + "kWh": 0, + "friendly_name": "Predbat car charging slot" + postfix, + "icon": "mdi:home-lightning-bolt-outline", + "solar_surplus": True, + }, + ) + + # Publish solar surplus observability sensor + any_surplus = any(self.car_charging_solar_surplus_active) if self.car_charging_solar_surplus_active else False + self.dashboard_item( + "binary_sensor." + self.prefix + "_car_charging_solar_surplus", + state="on" if any_surplus else "off", + attributes={ + "friendly_name": "Predbat car charging on solar surplus", + "icon": "mdi:solar-power", + "cars_active": [i for i, a in enumerate(self.car_charging_solar_surplus_active) if a], + }, + ) + # Set the charge/discharge status information self.set_charge_export_status(isCharging, isExporting, not (isCharging or isExporting)) self.isCharging = isCharging From a408410b2ada227072b45f1386872f8fa6dba87b Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:41:16 +0200 Subject: [PATCH 05/24] Add documentation for solar surplus car charging --- docs/car-charging.md | 51 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/docs/car-charging.md b/docs/car-charging.md index 7c11e43f1..4ae2a4118 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -552,6 +552,57 @@ Enter '40.1' into 'Car Manual SoC' and '80%' into 'Car Max charge'. Once the charger is switched to **true** and your Car Max charge (target SoC) % is higher than the kWh currently in the car, Predbat will plan and charge the car with the kW that are needed to reach the target SoC. +## Solar Surplus Car Charging + +When your house battery is full (or solar generation exceeds what the battery can absorb), excess solar power is exported to the grid at +typically low export rates. Solar surplus car charging automatically diverts this excess into your EV instead, making better use of your +solar generation. + +### How it works + +Every 5 minutes Predbat checks whether you are exporting power to the grid. If the export exceeds your car's charge rate +(minus a configurable shortfall allowance), it turns on `binary_sensor.predbat_car_charging_slot` — the same sensor your +existing car charging automation already watches. + +Surplus car charging will **not** activate during force export windows (when Predbat is deliberately exporting battery to the grid +for profit). It only captures genuinely excess solar that would otherwise be exported at low rates. + +Built-in hysteresis (200W deadband) prevents the charger from flapping on and off due to passing clouds. When the car is already +surplus-charging, Predbat accounts for the car's consumption when evaluating whether surplus is still available. + +### Configuration + +Enable the feature with these Predbat entities: + +- **switch.predbat_car_charging_solar_surplus** — Master switch to enable solar surplus car charging (default: Off). +- **input_number.predbat_car_charging_solar_surplus_threshold** — Shortfall allowance in Watts (default: 500W). + This is how many Watts short of the car charge rate the solar export can be and still trigger charging. + For example, if your car charges at 7.4kW and the threshold is 500W, charging activates when export reaches 6.9kW. +- **switch.predbat_car_charging_solar_surplus_ignore_limit** — When On (default), surplus charging will charge the car + past the configured charge limit. This is useful because the energy would otherwise be wasted — even if your car is at 80% + target, surplus solar can top it up further. + +### Sensors + +- **binary_sensor.predbat_car_charging_slot** gains a `solar_surplus: true` attribute when activated by surplus (rather than + a planned charging window). +- **binary_sensor.predbat_car_charging_solar_surplus** — Dedicated sensor showing whether surplus charging is currently active. + +### Interaction with other settings + +- **switch.predbat_car_charging_from_battery** — When Off, Predbat prevents the house battery from discharging into the car + during surplus charging, just as it does for planned charging slots. +- The car must be plugged in (`car_charging_planned` sensor reporting true) for surplus charging to activate. +- Only one car will surplus-charge at a time (the first eligible car in order). +- If a planned charging slot is already active, surplus detection still runs but does not override the planned slot. + +### Tips + +- Your Home Assistant automation that watches `binary_sensor.predbat_car_charging_slot` should use a `for:` debounce + (e.g. `for: "00:00:15"`) to avoid reacting to brief state transitions during Predbat's update cycle. +- The status sensor (`predbat.status`) will show "Hold for car (solar)" when battery discharge is being prevented + during surplus car charging. + ## Example: Separating car charging costs for multiple cars Predbat provides **predbat.cost_today_car** and **predbat.cost_total_car** which give the cost today and total accumulated cost for all car charging. From ab9edb391393b34a5e88d96676ac5d96eb5700d2 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:44:44 +0200 Subject: [PATCH 06/24] Add unit tests for solar surplus car charging --- apps/predbat/tests/test_execute.py | 174 ++++++++++++++++++++++++++++- apps/predbat/tests/test_infra.py | 8 ++ 2 files changed, 181 insertions(+), 1 deletion(-) diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index b7b361116..9b7adb8f2 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -210,6 +210,13 @@ def run_execute_test( car_soc=0, battery_temperature=20, assert_button_push=False, + car_charging_solar_surplus=False, + car_charging_solar_surplus_threshold=500, + car_charging_solar_surplus_ignore_limit=True, + car_charging_planned=None, + grid_power=0, + battery_power=0, + assert_solar_surplus_active=None, ): print("Run scenario {}".format(name)) my_predbat.log("Run scenario {}".format(name)) @@ -299,6 +306,17 @@ def run_execute_test( my_predbat.car_energy_reported_load = car_energy_reported_load my_predbat.car_charging_soc[0] = car_soc + # Solar surplus car charging setup + my_predbat.car_charging_solar_surplus = car_charging_solar_surplus + my_predbat.car_charging_solar_surplus_threshold = car_charging_solar_surplus_threshold + my_predbat.car_charging_solar_surplus_ignore_limit = car_charging_solar_surplus_ignore_limit + if car_charging_planned is not None: + my_predbat.car_charging_planned = car_charging_planned + else: + my_predbat.car_charging_planned = [False] * my_predbat.num_cars + my_predbat.grid_power = grid_power + my_predbat.battery_power = battery_power + # Shift on plan? if update_plan: my_predbat.plan_last_updated = my_predbat.now_utc @@ -355,7 +373,7 @@ def run_execute_test( assert_soc_target_force = ( assert_immediate_soc_target - if assert_status in ["Charging", "Charging, Hold for car", "Hold charging", "Freeze charging", "Hold charging, Hold for iBoost", "Hold charging, Hold for car", "Freeze charging, Hold for iBoost", "Hold for car", "Hold for iBoost"] + if assert_status in ["Charging", "Charging, Hold for car", "Hold charging", "Freeze charging", "Hold charging, Hold for iBoost", "Hold charging, Hold for car", "Freeze charging, Hold for iBoost", "Hold for car", "Hold for iBoost", "Hold for car (solar)"] else 0 ) if not set_charge_window: @@ -390,6 +408,13 @@ def run_execute_test( print("ERROR: isExporting should be {} for status '{}' got {}".format(expected_is_exporting, assert_status, my_predbat.isExporting)) failed = True + # Validate solar surplus active state + if assert_solar_surplus_active is not None: + actual = my_predbat.car_charging_solar_surplus_active + if actual != assert_solar_surplus_active: + print("ERROR: car_charging_solar_surplus_active should be {} got {}".format(assert_solar_surplus_active, actual)) + failed = True + my_predbat.minutes_now = 12 * 60 return failed @@ -2489,4 +2514,151 @@ def run_execute_tests(my_predbat): if failed: return failed + # Solar surplus car charging tests + print("**** Solar surplus car charging tests ****\n") + + # Surplus activates when grid export exceeds threshold + failed |= run_execute_test( + my_predbat, + "solar_surplus_activates", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + grid_power=7500, # Exporting 7500W + battery_power=0, # Battery idle + car_charging_from_battery=False, + assert_status="Hold for car (solar)", + assert_pause_discharge=True, + assert_solar_surplus_active=[True], + ) + if failed: + return failed + + # Surplus does NOT activate during force export + failed |= run_execute_test( + my_predbat, + "solar_surplus_blocked_during_export", + set_charge_window=True, + set_export_window=True, + export_window_best=export_window_best, + export_limits_best=export_limits_best, + soc_kw=10, + car_charging_solar_surplus=True, + car_charging_planned=[True], + grid_power=7500, + battery_power=0, + assert_status="Exporting", + assert_force_export=True, + assert_discharge_start_time_minutes=my_predbat.minutes_now, + assert_discharge_end_time_minutes=my_predbat.minutes_now + 61, + assert_soc_target=0, + assert_immediate_soc_target=0, + assert_solar_surplus_active=[False], + ) + if failed: + return failed + + # Surplus does NOT activate when export is below threshold + failed |= run_execute_test( + my_predbat, + "solar_surplus_below_threshold", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + grid_power=3000, # Only 3kW export, car needs ~7kW + battery_power=0, + assert_status="Demand", + assert_solar_surplus_active=[False], + ) + if failed: + return failed + + # Surplus does NOT activate when car is not plugged in + failed |= run_execute_test( + my_predbat, + "solar_surplus_car_not_plugged_in", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[False], + grid_power=7500, + battery_power=0, + assert_status="Demand", + assert_solar_surplus_active=[False], + ) + if failed: + return failed + + # Surplus does NOT activate when battery is discharging + failed |= run_execute_test( + my_predbat, + "solar_surplus_battery_discharging", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + grid_power=7500, + battery_power=500, # Battery discharging 500W (above hysteresis) + assert_status="Demand", + assert_solar_surplus_active=[False], + ) + if failed: + return failed + + # Surplus respects ignore_limit=False when car is at limit + failed |= run_execute_test( + my_predbat, + "solar_surplus_at_limit_no_ignore", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_solar_surplus_ignore_limit=False, + car_charging_planned=[True], + car_soc=100, # Car fully charged + grid_power=7500, + battery_power=0, + assert_status="Demand", + assert_solar_surplus_active=[False], + ) + if failed: + return failed + + # Surplus ignores limit when ignore_limit=True (default) and car is at limit + failed |= run_execute_test( + my_predbat, + "solar_surplus_at_limit_with_ignore", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_solar_surplus_ignore_limit=True, + car_charging_planned=[True], + car_soc=100, + grid_power=7500, + battery_power=0, + car_charging_from_battery=False, + assert_status="Hold for car (solar)", + assert_pause_discharge=True, + assert_solar_surplus_active=[True], + ) + if failed: + return failed + + # Surplus does NOT activate when feature is disabled + failed |= run_execute_test( + my_predbat, + "solar_surplus_feature_disabled", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=False, + car_charging_planned=[True], + grid_power=7500, + battery_power=0, + assert_status="Demand", + assert_solar_surplus_active=[False], + ) + if failed: + return failed + return failed diff --git a/apps/predbat/tests/test_infra.py b/apps/predbat/tests/test_infra.py index fee51563b..f24a47cbb 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -373,6 +373,9 @@ def get_default_config(self): "car_charging_manual_soc": False, "car_charging_threshold": 60.0, "car_charging_energy_scale": 1.0, + "car_charging_solar_surplus": False, + "car_charging_solar_surplus_threshold": 500, + "car_charging_solar_surplus_ignore_limit": True, "forecast_plan_hours": 8, "inverter_clock_skew_start": 0, "inverter_clock_skew_end": 0, @@ -485,6 +488,11 @@ def reset_inverter(my_predbat): my_predbat.car_charging_from_battery = True my_predbat.car_charging_limit = [100.0, 100.0, 100.0, 100.0] my_predbat.car_charging_soc = [0, 0, 0, 0] + my_predbat.car_charging_solar_surplus = False + my_predbat.car_charging_solar_surplus_threshold = 500 + my_predbat.car_charging_solar_surplus_ignore_limit = True + my_predbat.car_charging_solar_surplus_active = [] + my_predbat._car_surplus_prev = [] my_predbat.iboost_enable = False my_predbat.iboost_solar = False my_predbat.iboost_gas = False From 9ef84a2430607fd7fe139a9751ebb0fe5eacd334 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 15:57:15 +0200 Subject: [PATCH 07/24] Fix test assertions and formatting for solar surplus car charging --- .cspell/custom-dictionary-workspace.txt | 1 + apps/predbat/execute.py | 6 +----- apps/predbat/tests/test_execute.py | 13 ++++++++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 2961962f6..8bf19b3f2 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -76,6 +76,7 @@ dateutil dayname daynumber daysymbol +deadband dedup dend denorm diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index a7c3543b8..3191a1ed1 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -457,11 +457,7 @@ def execute_plan(self): self.car_charging_solar_surplus_active[car_n] = True if self.car_charging_solar_surplus_active[car_n]: - self.log( - "Solar surplus car charging active for car {}: export {}W (effective {}W), rate {}W, threshold {}W".format( - car_n, int(self.grid_power), int(effective_export), int(car_rate_w), int(threshold) - ) - ) + self.log("Solar surplus car charging active for car {}: export {}W (effective {}W), rate {}W, threshold {}W".format(car_n, int(self.grid_power), int(effective_export), int(car_rate_w), int(threshold))) break # One car at a time from surplus self._car_surplus_prev = list(self.car_charging_solar_surplus_active) diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index 9b7adb8f2..9288559b0 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -310,6 +310,9 @@ def run_execute_test( my_predbat.car_charging_solar_surplus = car_charging_solar_surplus my_predbat.car_charging_solar_surplus_threshold = car_charging_solar_surplus_threshold my_predbat.car_charging_solar_surplus_ignore_limit = car_charging_solar_surplus_ignore_limit + my_predbat.car_charging_solar_surplus_active = [False] * max(my_predbat.num_cars, 1) + my_predbat._car_surplus_prev = [False] * max(my_predbat.num_cars, 1) + my_predbat.car_charging_rate = [7.4] * max(my_predbat.num_cars, 1) if car_charging_planned is not None: my_predbat.car_charging_planned = car_charging_planned else: @@ -373,7 +376,8 @@ def run_execute_test( assert_soc_target_force = ( assert_immediate_soc_target - if assert_status in ["Charging", "Charging, Hold for car", "Hold charging", "Freeze charging", "Hold charging, Hold for iBoost", "Hold charging, Hold for car", "Freeze charging, Hold for iBoost", "Hold for car", "Hold for iBoost", "Hold for car (solar)"] + if assert_status + in ["Charging", "Charging, Hold for car", "Hold charging", "Freeze charging", "Hold charging, Hold for iBoost", "Hold charging, Hold for car", "Freeze charging, Hold for iBoost", "Hold for car", "Hold for iBoost", "Hold for car (solar)"] else 0 ) if not set_charge_window: @@ -2530,6 +2534,7 @@ def run_execute_tests(my_predbat): car_charging_from_battery=False, assert_status="Hold for car (solar)", assert_pause_discharge=True, + assert_immediate_soc_target=0, assert_solar_surplus_active=[True], ) if failed: @@ -2543,16 +2548,17 @@ def run_execute_tests(my_predbat): set_export_window=True, export_window_best=export_window_best, export_limits_best=export_limits_best, - soc_kw=10, + soc_kw=100, car_charging_solar_surplus=True, car_charging_planned=[True], + car_charging_from_battery=True, + car_slot=charge_window_best_slot, grid_power=7500, battery_power=0, assert_status="Exporting", assert_force_export=True, assert_discharge_start_time_minutes=my_predbat.minutes_now, assert_discharge_end_time_minutes=my_predbat.minutes_now + 61, - assert_soc_target=0, assert_immediate_soc_target=0, assert_solar_surplus_active=[False], ) @@ -2640,6 +2646,7 @@ def run_execute_tests(my_predbat): car_charging_from_battery=False, assert_status="Hold for car (solar)", assert_pause_discharge=True, + assert_immediate_soc_target=0, assert_solar_surplus_active=[True], ) if failed: From f2ef1738237c562b788c4830f789327656b38d7d Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 17 Apr 2026 07:24:11 +0200 Subject: [PATCH 08/24] Fix surplus detection running per-inverter and match sensor attribute shape Co-Authored-By: Claude Opus 4.6 --- apps/predbat/execute.py | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 3191a1ed1..d4b52faa3 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -427,9 +427,10 @@ def execute_plan(self): status_freeze_export = " [Freeze exporting]" - # Solar surplus car charging - detect excess solar export and activate car charging - self.car_charging_solar_surplus_active = [False] * self.num_cars - if self.car_charging_solar_surplus and self.num_cars > 0 and not isExporting: + # Solar surplus car charging - detect excess solar export and activate car charging (once, not per-inverter) + if inverter.id == 0: + self.car_charging_solar_surplus_active = [False] * self.num_cars + if inverter.id == 0 and self.car_charging_solar_surplus and self.num_cars > 0 and not isExporting: surplus_hysteresis = 200 # W deadband to prevent flapping was_active = getattr(self, "_car_surplus_prev", [False] * self.num_cars) for car_n in range(self.num_cars): @@ -461,7 +462,7 @@ def execute_plan(self): break # One car at a time from surplus self._car_surplus_prev = list(self.car_charging_solar_surplus_active) - # Car charging from battery disable? + # Car charging from battery disable? (runs per-inverter for discharge hold) carHolding = False if self.set_charge_window and not self.car_charging_from_battery and self.car_energy_reported_load: for car_n in range(self.num_cars): @@ -666,9 +667,9 @@ def execute_plan(self): "binary_sensor." + self.prefix + "_car_charging_slot" + postfix, state="on", attributes={ - "planned": "solar_surplus", + "planned": [], "cost": 0, - "kWh": 0, + "kwh": 0, "friendly_name": "Predbat car charging slot" + postfix, "icon": "mdi:home-lightning-bolt-outline", "solar_surplus": True, From 154f330c3b6b02a059f82bbb75314c85f0b050fa Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Sun, 19 Apr 2026 12:43:44 +0200 Subject: [PATCH 09/24] Reset _car_surplus_prev in reset() and drop getattr fallback --- apps/predbat/execute.py | 5 +++-- apps/predbat/predbat.py | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index d4b52faa3..e0a30335b 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -432,7 +432,8 @@ def execute_plan(self): self.car_charging_solar_surplus_active = [False] * self.num_cars if inverter.id == 0 and self.car_charging_solar_surplus and self.num_cars > 0 and not isExporting: surplus_hysteresis = 200 # W deadband to prevent flapping - was_active = getattr(self, "_car_surplus_prev", [False] * self.num_cars) + if len(self._car_surplus_prev) != self.num_cars: + self._car_surplus_prev = [False] * self.num_cars for car_n in range(self.num_cars): if not self.car_charging_planned[car_n]: continue @@ -444,7 +445,7 @@ def execute_plan(self): # When car was surplus-charging last cycle, add back its load to get true available export effective_export = self.grid_power - previously_active = car_n < len(was_active) and was_active[car_n] + previously_active = self._car_surplus_prev[car_n] if previously_active: effective_export += car_rate_w diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index aa3a52438..be333404b 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -601,6 +601,7 @@ def reset(self): self.discharge_rate_now = 0 self.car_charging_hold = False self.car_charging_solar_surplus_active = [] + self._car_surplus_prev = [] self.car_charging_manual_soc = [] self.car_charging_threshold = 99 self.car_charging_energy = {} From f60f2a4e418bf6ebf691b984648950ac26a3df3f Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 13:04:07 +0200 Subject: [PATCH 10/24] Replace solar surplus ignore_limit switch with a configurable SoC cap --- apps/predbat/config.py | 13 +++++++--- apps/predbat/execute.py | 2 +- apps/predbat/fetch.py | 2 +- apps/predbat/tests/test_execute.py | 40 ++++++++++++++++++++++-------- apps/predbat/tests/test_infra.py | 4 +-- docs/car-charging.md | 7 +++--- 6 files changed, 47 insertions(+), 21 deletions(-) diff --git a/apps/predbat/config.py b/apps/predbat/config.py index 4765507cb..799c521ab 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -770,10 +770,15 @@ "enable": "car_charging_solar_surplus", }, { - "name": "car_charging_solar_surplus_ignore_limit", - "friendly_name": "Car charging solar surplus ignore charge limit", - "type": "switch", - "default": True, + "name": "car_charging_solar_surplus_limit", + "friendly_name": "Car charging solar surplus SoC cap", + "type": "input_number", + "min": 0, + "max": 100, + "step": 5, + "unit": "%", + "icon": "mdi:ev-station", + "default": 100, "enable": "car_charging_solar_surplus", }, { diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index e0a30335b..3bc355573 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -437,7 +437,7 @@ def execute_plan(self): for car_n in range(self.num_cars): if not self.car_charging_planned[car_n]: continue - if not self.car_charging_solar_surplus_ignore_limit and self.car_charging_soc[car_n] >= self.car_charging_limit[car_n]: + if self.car_charging_soc[car_n] >= self.car_charging_solar_surplus_limit: continue car_rate_w = self.car_charging_rate[car_n] * 1000 diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index cd4728edd..fe6691362 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -2348,7 +2348,7 @@ def fetch_config_options(self): self.car_charging_energy_scale = self.get_arg("car_charging_energy_scale") self.car_charging_solar_surplus = self.get_arg("car_charging_solar_surplus") self.car_charging_solar_surplus_threshold = float(self.get_arg("car_charging_solar_surplus_threshold")) - self.car_charging_solar_surplus_ignore_limit = self.get_arg("car_charging_solar_surplus_ignore_limit") + self.car_charging_solar_surplus_limit = float(self.get_arg("car_charging_solar_surplus_limit")) # Update list of slot times self.manual_charge_times = self.manual_times("manual_charge") diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index 9288559b0..ff1cf81c3 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -212,7 +212,7 @@ def run_execute_test( assert_button_push=False, car_charging_solar_surplus=False, car_charging_solar_surplus_threshold=500, - car_charging_solar_surplus_ignore_limit=True, + car_charging_solar_surplus_limit=100, car_charging_planned=None, grid_power=0, battery_power=0, @@ -309,7 +309,7 @@ def run_execute_test( # Solar surplus car charging setup my_predbat.car_charging_solar_surplus = car_charging_solar_surplus my_predbat.car_charging_solar_surplus_threshold = car_charging_solar_surplus_threshold - my_predbat.car_charging_solar_surplus_ignore_limit = car_charging_solar_surplus_ignore_limit + my_predbat.car_charging_solar_surplus_limit = car_charging_solar_surplus_limit my_predbat.car_charging_solar_surplus_active = [False] * max(my_predbat.num_cars, 1) my_predbat._car_surplus_prev = [False] * max(my_predbat.num_cars, 1) my_predbat.car_charging_rate = [7.4] * max(my_predbat.num_cars, 1) @@ -2613,16 +2613,37 @@ def run_execute_tests(my_predbat): if failed: return failed - # Surplus respects ignore_limit=False when car is at limit + # Surplus activates when car SoC is below the surplus limit (allowing over Predbat's target) failed |= run_execute_test( my_predbat, - "solar_surplus_at_limit_no_ignore", + "solar_surplus_limit_allows_over_target", set_charge_window=True, set_export_window=True, car_charging_solar_surplus=True, - car_charging_solar_surplus_ignore_limit=False, + car_charging_solar_surplus_limit=90, car_charging_planned=[True], - car_soc=100, # Car fully charged + car_soc=80, + grid_power=7500, + battery_power=0, + car_charging_from_battery=False, + assert_status="Hold for car (solar)", + assert_pause_discharge=True, + assert_immediate_soc_target=0, + assert_solar_surplus_active=[True], + ) + if failed: + return failed + + # Surplus stops when car SoC reaches the surplus limit + failed |= run_execute_test( + my_predbat, + "solar_surplus_stops_at_surplus_limit", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_solar_surplus_limit=90, + car_charging_planned=[True], + car_soc=90, grid_power=7500, battery_power=0, assert_status="Demand", @@ -2631,16 +2652,15 @@ def run_execute_tests(my_predbat): if failed: return failed - # Surplus ignores limit when ignore_limit=True (default) and car is at limit + # Default surplus limit of 100 allows charging up to full failed |= run_execute_test( my_predbat, - "solar_surplus_at_limit_with_ignore", + "solar_surplus_limit_default_100", set_charge_window=True, set_export_window=True, car_charging_solar_surplus=True, - car_charging_solar_surplus_ignore_limit=True, car_charging_planned=[True], - car_soc=100, + car_soc=99, grid_power=7500, battery_power=0, car_charging_from_battery=False, diff --git a/apps/predbat/tests/test_infra.py b/apps/predbat/tests/test_infra.py index f24a47cbb..c7eee6a40 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -375,7 +375,7 @@ def get_default_config(self): "car_charging_energy_scale": 1.0, "car_charging_solar_surplus": False, "car_charging_solar_surplus_threshold": 500, - "car_charging_solar_surplus_ignore_limit": True, + "car_charging_solar_surplus_limit": 100, "forecast_plan_hours": 8, "inverter_clock_skew_start": 0, "inverter_clock_skew_end": 0, @@ -490,7 +490,7 @@ def reset_inverter(my_predbat): my_predbat.car_charging_soc = [0, 0, 0, 0] my_predbat.car_charging_solar_surplus = False my_predbat.car_charging_solar_surplus_threshold = 500 - my_predbat.car_charging_solar_surplus_ignore_limit = True + my_predbat.car_charging_solar_surplus_limit = 100 my_predbat.car_charging_solar_surplus_active = [] my_predbat._car_surplus_prev = [] my_predbat.iboost_enable = False diff --git a/docs/car-charging.md b/docs/car-charging.md index 4ae2a4118..cc7d902ef 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -578,9 +578,10 @@ Enable the feature with these Predbat entities: - **input_number.predbat_car_charging_solar_surplus_threshold** — Shortfall allowance in Watts (default: 500W). This is how many Watts short of the car charge rate the solar export can be and still trigger charging. For example, if your car charges at 7.4kW and the threshold is 500W, charging activates when export reaches 6.9kW. -- **switch.predbat_car_charging_solar_surplus_ignore_limit** — When On (default), surplus charging will charge the car - past the configured charge limit. This is useful because the energy would otherwise be wasted — even if your car is at 80% - target, surplus solar can top it up further. +- **input_number.predbat_car_charging_solar_surplus_limit** — Upper SoC cap for surplus charging, as a percentage (default: 100%). + Predbat manages scheduled charging up to `car_charging_limit` as normal; any excess solar can top the car up to this cap. + For example, with `car_charging_limit` of 80% and `car_charging_solar_surplus_limit` of 90%, Predbat will plan charging to + reach 80% and allow surplus to push the car up to 90%. Leave at 100% (default) to always use available surplus. ### Sensors From 0aea12050512355ab4db43fd914d08226c679126 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 13:10:58 +0200 Subject: [PATCH 11/24] Extract solar surplus detection into detect_car_solar_surplus helper --- apps/predbat/execute.py | 89 ++++++++++++++++++++++++----------------- 1 file changed, 53 insertions(+), 36 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 3bc355573..ebb3e1efb 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -53,6 +53,11 @@ def execute_plan(self): isCharging = False isExporting = False + + # Solar surplus car charging runs once up-front since it only reads global state + in_force_export_window = bool(self.set_export_window and self.export_window_best and self.minutes_now >= self.export_window_best[0]["start"] and self.minutes_now < self.export_window_best[0]["end"] and self.export_limits_best[0] < 100.0) + self.detect_car_solar_surplus(in_force_export_window) + for inverter in self.inverters: if inverter.id not in self.count_inverter_writes: self.count_inverter_writes[inverter.id] = 0 @@ -427,42 +432,6 @@ def execute_plan(self): status_freeze_export = " [Freeze exporting]" - # Solar surplus car charging - detect excess solar export and activate car charging (once, not per-inverter) - if inverter.id == 0: - self.car_charging_solar_surplus_active = [False] * self.num_cars - if inverter.id == 0 and self.car_charging_solar_surplus and self.num_cars > 0 and not isExporting: - surplus_hysteresis = 200 # W deadband to prevent flapping - if len(self._car_surplus_prev) != self.num_cars: - self._car_surplus_prev = [False] * self.num_cars - for car_n in range(self.num_cars): - if not self.car_charging_planned[car_n]: - continue - if self.car_charging_soc[car_n] >= self.car_charging_solar_surplus_limit: - continue - - car_rate_w = self.car_charging_rate[car_n] * 1000 - threshold = self.car_charging_solar_surplus_threshold - - # When car was surplus-charging last cycle, add back its load to get true available export - effective_export = self.grid_power - previously_active = self._car_surplus_prev[car_n] - if previously_active: - effective_export += car_rate_w - - if previously_active: - # Currently on: lower bar to stay on, no battery check needed - if effective_export >= car_rate_w - threshold - surplus_hysteresis: - self.car_charging_solar_surplus_active[car_n] = True - else: - # Currently off: higher bar to turn on, require battery not discharging - if effective_export >= car_rate_w - threshold + surplus_hysteresis and self.battery_power <= surplus_hysteresis: - self.car_charging_solar_surplus_active[car_n] = True - - if self.car_charging_solar_surplus_active[car_n]: - self.log("Solar surplus car charging active for car {}: export {}W (effective {}W), rate {}W, threshold {}W".format(car_n, int(self.grid_power), int(effective_export), int(car_rate_w), int(threshold))) - break # One car at a time from surplus - self._car_surplus_prev = list(self.car_charging_solar_surplus_active) - # Car charging from battery disable? (runs per-inverter for discharge hold) carHolding = False if self.set_charge_window and not self.car_charging_from_battery and self.car_energy_reported_load: @@ -704,6 +673,54 @@ def execute_plan(self): return status, status_extra + def detect_car_solar_surplus(self, in_force_export_window): + """ + Detect excess solar export and mark cars as eligible to charge from surplus. + + Populates ``self.car_charging_solar_surplus_active`` (per car) and updates + ``self._car_surplus_prev`` for the next cycle's hysteresis check. Uses only + global state (grid/battery power, car config), so runs once per execute_plan + rather than per inverter. + """ + self.car_charging_solar_surplus_active = [False] * self.num_cars + if not self.car_charging_solar_surplus or self.num_cars <= 0 or in_force_export_window: + self._car_surplus_prev = list(self.car_charging_solar_surplus_active) + return + + surplus_hysteresis = 200 # W deadband to prevent flapping + if len(self._car_surplus_prev) != self.num_cars: + self._car_surplus_prev = [False] * self.num_cars + + for car_n in range(self.num_cars): + if not self.car_charging_planned[car_n]: + continue + if self.car_charging_soc[car_n] >= self.car_charging_solar_surplus_limit: + continue + + car_rate_w = self.car_charging_rate[car_n] * 1000 + threshold = self.car_charging_solar_surplus_threshold + + # When car was surplus-charging last cycle, add back its load to get true available export + effective_export = self.grid_power + previously_active = self._car_surplus_prev[car_n] + if previously_active: + effective_export += car_rate_w + + if previously_active: + # Currently on: lower bar to stay on, no battery check needed + if effective_export >= car_rate_w - threshold - surplus_hysteresis: + self.car_charging_solar_surplus_active[car_n] = True + else: + # Currently off: higher bar to turn on, require battery not discharging + if effective_export >= car_rate_w - threshold + surplus_hysteresis and self.battery_power <= surplus_hysteresis: + self.car_charging_solar_surplus_active[car_n] = True + + if self.car_charging_solar_surplus_active[car_n]: + self.log("Solar surplus car charging active for car {}: export {}W (effective {}W), rate {}W, threshold {}W".format(car_n, int(self.grid_power), int(effective_export), int(car_rate_w), int(threshold))) + break # One car at a time from surplus + + self._car_surplus_prev = list(self.car_charging_solar_surplus_active) + def adjust_battery_target_multi(self, inverter, soc, is_charging, is_exporting, isFreezeCharge=False, check=False): """ Adjust target SoC based on the current SoC of all the inverters accounting for their From 870695c4722816862ed3d1ee8d228cb2d4c7f781 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 13:11:12 +0200 Subject: [PATCH 12/24] Tweak solar surplus docs wording and line wrapping --- docs/car-charging.md | 29 +++++++++-------------------- 1 file changed, 9 insertions(+), 20 deletions(-) diff --git a/docs/car-charging.md b/docs/car-charging.md index cc7d902ef..1aecbf127 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -554,21 +554,15 @@ Predbat will plan and charge the car with the kW that are needed to reach the ta ## Solar Surplus Car Charging -When your house battery is full (or solar generation exceeds what the battery can absorb), excess solar power is exported to the grid at -typically low export rates. Solar surplus car charging automatically diverts this excess into your EV instead, making better use of your -solar generation. +When your house battery is full (or solar generation exceeds what the battery can absorb), excess solar power is exported to the grid at typically low export rates. Solar surplus car charging automatically diverts this excess into your EV instead, making better use of your solar generation. ### How it works -Every 5 minutes Predbat checks whether you are exporting power to the grid. If the export exceeds your car's charge rate -(minus a configurable shortfall allowance), it turns on `binary_sensor.predbat_car_charging_slot` — the same sensor your -existing car charging automation already watches. +Every 5 minutes Predbat checks whether you are exporting power to the grid. If the export exceeds your car's charge rate (minus a configurable shortfall allowance), it turns on `binary_sensor.predbat_car_charging_slot` — the same sensor your existing car charging automation already watches. -Surplus car charging will **not** activate during force export windows (when Predbat is deliberately exporting battery to the grid -for profit). It only captures genuinely excess solar that would otherwise be exported at low rates. +Surplus car charging will **not** activate during force export windows (when Predbat is deliberately exporting battery to the grid for profit). It only captures genuinely excess solar that would otherwise be exported at low rates. -Built-in hysteresis (200W deadband) prevents the charger from flapping on and off due to passing clouds. When the car is already -surplus-charging, Predbat accounts for the car's consumption when evaluating whether surplus is still available. +Built-in hysteresis (200W) prevents the charger from flapping on and off due to passing clouds. When the car is already surplus-charging, Predbat accounts for the car's consumption (`input_number.predbat_car_charging_rate`) when evaluating whether surplus is still available. ### Configuration @@ -580,29 +574,24 @@ Enable the feature with these Predbat entities: For example, if your car charges at 7.4kW and the threshold is 500W, charging activates when export reaches 6.9kW. - **input_number.predbat_car_charging_solar_surplus_limit** — Upper SoC cap for surplus charging, as a percentage (default: 100%). Predbat manages scheduled charging up to `car_charging_limit` as normal; any excess solar can top the car up to this cap. - For example, with `car_charging_limit` of 80% and `car_charging_solar_surplus_limit` of 90%, Predbat will plan charging to - reach 80% and allow surplus to push the car up to 90%. Leave at 100% (default) to always use available surplus. + For example, with `car_charging_limit` of 80% and `car_charging_solar_surplus_limit` of 90%, Predbat will plan charging to reach 80% and allow surplus to push the car up to 90%. Leave at 100% (default) to always use available surplus. ### Sensors -- **binary_sensor.predbat_car_charging_slot** gains a `solar_surplus: true` attribute when activated by surplus (rather than - a planned charging window). +- **binary_sensor.predbat_car_charging_slot** gains a `solar_surplus: true` attribute when activated by surplus (rather than a planned charging window). - **binary_sensor.predbat_car_charging_solar_surplus** — Dedicated sensor showing whether surplus charging is currently active. ### Interaction with other settings -- **switch.predbat_car_charging_from_battery** — When Off, Predbat prevents the house battery from discharging into the car - during surplus charging, just as it does for planned charging slots. +- **switch.predbat_car_charging_from_battery** — When Off, Predbat prevents the house battery from discharging into the car during surplus charging, just as it does for planned charging slots. - The car must be plugged in (`car_charging_planned` sensor reporting true) for surplus charging to activate. - Only one car will surplus-charge at a time (the first eligible car in order). - If a planned charging slot is already active, surplus detection still runs but does not override the planned slot. ### Tips -- Your Home Assistant automation that watches `binary_sensor.predbat_car_charging_slot` should use a `for:` debounce - (e.g. `for: "00:00:15"`) to avoid reacting to brief state transitions during Predbat's update cycle. -- The status sensor (`predbat.status`) will show "Hold for car (solar)" when battery discharge is being prevented - during surplus car charging. +- Your Home Assistant automation that watches `binary_sensor.predbat_car_charging_slot` should use a `for:` debounce (e.g. `for: "00:00:15"`) to avoid reacting to brief state transitions during Predbat's update cycle. +- The status sensor (`predbat.status`) will show "Hold for car (solar)" when battery discharge is being prevented during surplus car charging. ## Example: Separating car charging costs for multiple cars From 9f7c5dca5e192c84d502f30cda5fc9046f889902 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 16:49:45 +0200 Subject: [PATCH 13/24] Fix solar surplus SoC cap to compare kWh-to-kWh --- apps/predbat/execute.py | 4 +++- apps/predbat/tests/test_execute.py | 14 +++++++++++--- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index ebb3e1efb..ab02b0c92 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -694,7 +694,9 @@ def detect_car_solar_surplus(self, in_force_export_window): for car_n in range(self.num_cars): if not self.car_charging_planned[car_n]: continue - if self.car_charging_soc[car_n] >= self.car_charging_solar_surplus_limit: + # car_charging_soc is kWh; surplus_limit is a % — convert to kWh using the car's battery size + battery_size_kwh = self.car_charging_battery_size[car_n] if car_n < len(self.car_charging_battery_size) else 0 + if battery_size_kwh > 0 and self.car_charging_soc[car_n] >= battery_size_kwh * self.car_charging_solar_surplus_limit / 100.0: continue car_rate_w = self.car_charging_rate[car_n] * 1000 diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index ff1cf81c3..51f3226f6 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -214,6 +214,7 @@ def run_execute_test( car_charging_solar_surplus_threshold=500, car_charging_solar_surplus_limit=100, car_charging_planned=None, + car_battery_size=100.0, grid_power=0, battery_power=0, assert_solar_surplus_active=None, @@ -313,6 +314,7 @@ def run_execute_test( my_predbat.car_charging_solar_surplus_active = [False] * max(my_predbat.num_cars, 1) my_predbat._car_surplus_prev = [False] * max(my_predbat.num_cars, 1) my_predbat.car_charging_rate = [7.4] * max(my_predbat.num_cars, 1) + my_predbat.car_charging_battery_size = [car_battery_size] * max(my_predbat.num_cars, 1) if car_charging_planned is not None: my_predbat.car_charging_planned = car_charging_planned else: @@ -2614,6 +2616,7 @@ def run_execute_tests(my_predbat): return failed # Surplus activates when car SoC is below the surplus limit (allowing over Predbat's target) + # 75 kWh battery, 60 kWh == 80% SoC, cap at 90% == 67.5 kWh — under the cap, should activate failed |= run_execute_test( my_predbat, "solar_surplus_limit_allows_over_target", @@ -2622,7 +2625,8 @@ def run_execute_tests(my_predbat): car_charging_solar_surplus=True, car_charging_solar_surplus_limit=90, car_charging_planned=[True], - car_soc=80, + car_battery_size=75.0, + car_soc=60.0, grid_power=7500, battery_power=0, car_charging_from_battery=False, @@ -2635,6 +2639,7 @@ def run_execute_tests(my_predbat): return failed # Surplus stops when car SoC reaches the surplus limit + # 75 kWh battery, 67.5 kWh == 90% SoC, cap at 90% — at the cap, should not activate failed |= run_execute_test( my_predbat, "solar_surplus_stops_at_surplus_limit", @@ -2643,7 +2648,8 @@ def run_execute_tests(my_predbat): car_charging_solar_surplus=True, car_charging_solar_surplus_limit=90, car_charging_planned=[True], - car_soc=90, + car_battery_size=75.0, + car_soc=67.5, grid_power=7500, battery_power=0, assert_status="Demand", @@ -2653,6 +2659,7 @@ def run_execute_tests(my_predbat): return failed # Default surplus limit of 100 allows charging up to full + # 75 kWh battery, 74.25 kWh == 99% SoC, cap at 100% (default) — under the cap, should activate failed |= run_execute_test( my_predbat, "solar_surplus_limit_default_100", @@ -2660,7 +2667,8 @@ def run_execute_tests(my_predbat): set_export_window=True, car_charging_solar_surplus=True, car_charging_planned=[True], - car_soc=99, + car_battery_size=75.0, + car_soc=74.25, grid_power=7500, battery_power=0, car_charging_from_battery=False, From 68d7c3827cc07840d556fedf99b92a7f0a908865 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 16:49:47 +0200 Subject: [PATCH 14/24] Preserve planned slot attributes on solar surplus sensor override --- apps/predbat/execute.py | 24 +++++++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index ab02b0c92..a14547aaa 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -632,14 +632,32 @@ def execute_plan(self): if self.minutes_now >= window["start"] and self.minutes_now < window["end"] and window.get("kwh", 0) > 0: in_planned_slot = True if not in_planned_slot: + # Preserve the planned slot list and totals that publish_car_plan published, just flip state on + plan = [] + total_cost = 0.0 + total_kwh = 0.0 + for window in self.car_charging_slots[car_n]: + kwh = dp2(window["kwh"]) + cost = dp2(window["cost"]) + plan.append( + { + "start": self.time_abs_str(window["start"]), + "end": self.time_abs_str(window["end"]), + "kwh": kwh, + "average": dp2(window["average"]), + "cost": cost, + } + ) + total_kwh += kwh + total_cost += cost postfix = "" if car_n == 0 else "_" + str(car_n) self.dashboard_item( "binary_sensor." + self.prefix + "_car_charging_slot" + postfix, state="on", attributes={ - "planned": [], - "cost": 0, - "kwh": 0, + "planned": plan, + "cost": dp2(total_cost) if plan else None, + "kwh": dp2(total_kwh) if plan else None, "friendly_name": "Predbat car charging slot" + postfix, "icon": "mdi:home-lightning-bolt-outline", "solar_surplus": True, From 129f99e6785423fc7de8de3fa6bf208280e4d49b Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 17:06:01 +0200 Subject: [PATCH 15/24] Add solar surplus hysteresis test coverage for the stay-on branch --- apps/predbat/tests/test_execute.py | 44 +++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index 51f3226f6..454eb096b 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -215,6 +215,7 @@ def run_execute_test( car_charging_solar_surplus_limit=100, car_charging_planned=None, car_battery_size=100.0, + car_surplus_prev=None, grid_power=0, battery_power=0, assert_solar_surplus_active=None, @@ -312,7 +313,7 @@ def run_execute_test( my_predbat.car_charging_solar_surplus_threshold = car_charging_solar_surplus_threshold my_predbat.car_charging_solar_surplus_limit = car_charging_solar_surplus_limit my_predbat.car_charging_solar_surplus_active = [False] * max(my_predbat.num_cars, 1) - my_predbat._car_surplus_prev = [False] * max(my_predbat.num_cars, 1) + my_predbat._car_surplus_prev = list(car_surplus_prev) if car_surplus_prev is not None else [False] * max(my_predbat.num_cars, 1) my_predbat.car_charging_rate = [7.4] * max(my_predbat.num_cars, 1) my_predbat.car_charging_battery_size = [car_battery_size] * max(my_predbat.num_cars, 1) if car_charging_planned is not None: @@ -2680,6 +2681,47 @@ def run_execute_tests(my_predbat): if failed: return failed + # Hysteresis: when car was already surplus-charging, it stays on even though grid_power alone + # is below the turn-on threshold (car load is masking the real available export). + # car_rate=7400W, threshold=500W, hysteresis=200W → stay-on threshold on effective export is 6700W. + # grid_power=0 + car_rate 7400 = 7400 effective → >= 6700, stays on. + failed |= run_execute_test( + my_predbat, + "solar_surplus_hysteresis_stays_on", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + car_surplus_prev=[True], + grid_power=0, + battery_power=500, # battery discharging — intentionally ignored in the stay-on branch + car_charging_from_battery=False, + assert_status="Hold for car (solar)", + assert_pause_discharge=True, + assert_immediate_soc_target=0, + assert_solar_surplus_active=[True], + ) + if failed: + return failed + + # Hysteresis: when real surplus is gone (we're importing from grid even accounting for car load), + # surplus charging deactivates. grid_power=-1000 + car_rate 7400 = 6400 effective → < 6700, drops off. + failed |= run_execute_test( + my_predbat, + "solar_surplus_hysteresis_drops_off", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + car_surplus_prev=[True], + grid_power=-1000, + battery_power=0, + assert_status="Demand", + assert_solar_surplus_active=[False], + ) + if failed: + return failed + # Surplus does NOT activate when feature is disabled failed |= run_execute_test( my_predbat, From 794d22ad8d474d6367c7687a6c072615baac3c36 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:45:19 +0200 Subject: [PATCH 16/24] Document solar surplus prerequisites and battery protection --- docs/car-charging.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/car-charging.md b/docs/car-charging.md index 1aecbf127..64e532da2 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -564,6 +564,13 @@ Surplus car charging will **not** activate during force export windows (when Pre Built-in hysteresis (200W) prevents the charger from flapping on and off due to passing clouds. When the car is already surplus-charging, Predbat accounts for the car's consumption (`input_number.predbat_car_charging_rate`) when evaluating whether surplus is still available. +While surplus charging is active, Predbat also drops the feature off if the home battery is being discharged at more than 500W to feed the car (rather than simply being idle while solar covers the load). This stops a cloudy evening from quietly draining the battery into the car. + +### Prerequisites + +- **Grid power sign convention.** Surplus relies on your existing `grid_power` sensor: positive values must mean *exporting*. If your inverter integration reports the opposite sign, set `grid_power_invert: true` in `apps.yaml` (the same flag the rest of Predbat uses). Without that, surplus charging can fire while you are *importing*, running up the import bill instead of using free solar. +- **Read-only mode.** When Predbat is running read-only it cannot prevent the home battery from discharging into the car, so surplus detection is skipped entirely (the binary sensor stays `off`). + ### Configuration Enable the feature with these Predbat entities: From 45e7483bdfcbf5655df430fe5d54d2adf198f46d Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:46:12 +0200 Subject: [PATCH 17/24] Skip solar surplus detection in read-only mode --- apps/predbat/execute.py | 4 +++- apps/predbat/tests/test_execute.py | 23 +++++++++++++++++++++++ 2 files changed, 26 insertions(+), 1 deletion(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index a14547aaa..a9338599d 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -701,7 +701,9 @@ def detect_car_solar_surplus(self, in_force_export_window): rather than per inverter. """ self.car_charging_solar_surplus_active = [False] * self.num_cars - if not self.car_charging_solar_surplus or self.num_cars <= 0 or in_force_export_window: + # Skip in read-only mode: we cannot enforce battery-discharge protection, + # so don't trigger HA automations that would charge the car unprotected. + if not self.car_charging_solar_surplus or self.num_cars <= 0 or in_force_export_window or self.set_read_only: self._car_surplus_prev = list(self.car_charging_solar_surplus_active) return diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index 454eb096b..6d548b833 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2738,4 +2738,27 @@ def run_execute_tests(my_predbat): if failed: return failed + # Read-only mode skips surplus detection entirely. Predbat cannot enforce battery + # discharge protection while read-only, so we don't trigger the HA automation either. + # The inverter loop early-continues in read-only and skips its resetPause path, + # so clear pause flags from prior tests before running. + for inv in my_predbat.inverters: + inv.pause_discharge = False + inv.pause_charge = False + failed |= run_execute_test( + my_predbat, + "solar_surplus_read_only_skipped", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + read_only=True, + grid_power=7500, + battery_power=0, + assert_status="Read-Only", + assert_solar_surplus_active=[False], + ) + if failed: + return failed + return failed From 83bdd9c107358ff9498b3e14ba622bd5cc3df38d Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:46:58 +0200 Subject: [PATCH 18/24] Stop solar surplus draining home battery on the stay-on branch --- apps/predbat/execute.py | 9 +++++-- apps/predbat/tests/test_execute.py | 43 ++++++++++++++++++++++++++++-- 2 files changed, 48 insertions(+), 4 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index a9338599d..39f0b56a3 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -708,6 +708,10 @@ def detect_car_solar_surplus(self, in_force_export_window): return surplus_hysteresis = 200 # W deadband to prevent flapping + # Tolerance for transient battery discharge while surplus is asserted. + # Distinct from car_charging_solar_surplus_threshold (the user-facing + # shortfall allowance for solar export); not configurable by intent. + stay_on_battery_discharge_limit_w = 500 if len(self._car_surplus_prev) != self.num_cars: self._car_surplus_prev = [False] * self.num_cars @@ -729,8 +733,9 @@ def detect_car_solar_surplus(self, in_force_export_window): effective_export += car_rate_w if previously_active: - # Currently on: lower bar to stay on, no battery check needed - if effective_export >= car_rate_w - threshold - surplus_hysteresis: + # Currently on: lower bar to stay on, but require battery isn't being drained + # to feed the car (otherwise effective_export masks the real PV deficit). + if effective_export >= car_rate_w - threshold - surplus_hysteresis and self.battery_power <= stay_on_battery_discharge_limit_w: self.car_charging_solar_surplus_active[car_n] = True else: # Currently off: higher bar to turn on, require battery not discharging diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index 6d548b833..cdf5ba9f8 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2684,7 +2684,7 @@ def run_execute_tests(my_predbat): # Hysteresis: when car was already surplus-charging, it stays on even though grid_power alone # is below the turn-on threshold (car load is masking the real available export). # car_rate=7400W, threshold=500W, hysteresis=200W → stay-on threshold on effective export is 6700W. - # grid_power=0 + car_rate 7400 = 7400 effective → >= 6700, stays on. + # grid_power=0 + car_rate 7400 = 7400 effective → >= 6700, battery idle, stays on. failed |= run_execute_test( my_predbat, "solar_surplus_hysteresis_stays_on", @@ -2694,7 +2694,46 @@ def run_execute_tests(my_predbat): car_charging_planned=[True], car_surplus_prev=[True], grid_power=0, - battery_power=500, # battery discharging — intentionally ignored in the stay-on branch + battery_power=0, + car_charging_from_battery=False, + assert_status="Hold for car (solar)", + assert_pause_discharge=True, + assert_immediate_soc_target=0, + assert_solar_surplus_active=[True], + ) + if failed: + return failed + + # Stay-on branch must drop off when the home battery is being drained to feed the car. + # grid_power=0 + car_rate 7400 = 7400 effective (passes export check), but battery_power=600 + # exceeds the 500W stay-on battery discharge limit, so surplus deactivates. + failed |= run_execute_test( + my_predbat, + "solar_surplus_drains_battery_drops_off", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + car_surplus_prev=[True], + grid_power=0, + battery_power=600, + assert_status="Demand", + assert_solar_surplus_active=[False], + ) + if failed: + return failed + + # Stay-on branch tolerates small transient battery discharge under the 500W gate. + failed |= run_execute_test( + my_predbat, + "solar_surplus_battery_idle_stays_on", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + car_surplus_prev=[True], + grid_power=0, + battery_power=200, car_charging_from_battery=False, assert_status="Hold for car (solar)", assert_pause_discharge=True, From e10be00afe6d798290980c1e747dd504804b4082 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:47:35 +0200 Subject: [PATCH 19/24] Pause discharge for solar surplus without car_energy_reported_load --- apps/predbat/execute.py | 6 +++++- apps/predbat/tests/test_execute.py | 22 ++++++++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 39f0b56a3..25f7ab9fb 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -433,8 +433,12 @@ def execute_plan(self): status_freeze_export = " [Freeze exporting]" # Car charging from battery disable? (runs per-inverter for discharge hold) + # car_energy_reported_load is normally required (so Predbat can account for + # car load in history); for solar surplus we don't need that sensor since + # we already have grid_power and battery_power directly. carHolding = False - if self.set_charge_window and not self.car_charging_from_battery and self.car_energy_reported_load: + surplus_any = any(self.car_charging_solar_surplus_active) + if self.set_charge_window and not self.car_charging_from_battery and (self.car_energy_reported_load or surplus_any): for car_n in range(self.num_cars): surplus_active = car_n < len(self.car_charging_solar_surplus_active) and self.car_charging_solar_surplus_active[car_n] in_planned_slot = False diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index cdf5ba9f8..c4c8ee9e8 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2777,6 +2777,28 @@ def run_execute_tests(my_predbat): if failed: return failed + # Discharge-pause must fire even when car_energy_reported_load is False. The carHolding + # gate at execute_plan would otherwise block surplus pause-discharge when the user has + # not configured a separate car-energy sensor. Same setup as solar_surplus_activates. + failed |= run_execute_test( + my_predbat, + "solar_surplus_pauses_without_car_energy_sensor", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + car_energy_reported_load=False, + grid_power=7500, + battery_power=0, + car_charging_from_battery=False, + assert_status="Hold for car (solar)", + assert_pause_discharge=True, + assert_immediate_soc_target=0, + assert_solar_surplus_active=[True], + ) + if failed: + return failed + # Read-only mode skips surplus detection entirely. Predbat cannot enforce battery # discharge protection while read-only, so we don't trigger the HA automation either. # The inverter loop early-continues in read-only and skips its resetPause path, From 90421aa35f23a4d768baf7ba3106c47eae1028e6 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:48:03 +0200 Subject: [PATCH 20/24] Hoist solar surplus check out of per-inverter loop --- apps/predbat/execute.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 25f7ab9fb..2fc4a2c2b 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -57,6 +57,7 @@ def execute_plan(self): # Solar surplus car charging runs once up-front since it only reads global state in_force_export_window = bool(self.set_export_window and self.export_window_best and self.minutes_now >= self.export_window_best[0]["start"] and self.minutes_now < self.export_window_best[0]["end"] and self.export_limits_best[0] < 100.0) self.detect_car_solar_surplus(in_force_export_window) + surplus_any = any(self.car_charging_solar_surplus_active) for inverter in self.inverters: if inverter.id not in self.count_inverter_writes: @@ -437,7 +438,6 @@ def execute_plan(self): # car load in history); for solar surplus we don't need that sensor since # we already have grid_power and battery_power directly. carHolding = False - surplus_any = any(self.car_charging_solar_surplus_active) if self.set_charge_window and not self.car_charging_from_battery and (self.car_energy_reported_load or surplus_any): for car_n in range(self.num_cars): surplus_active = car_n < len(self.car_charging_solar_surplus_active) and self.car_charging_solar_surplus_active[car_n] From b8dc68c7891acc094892018e3a413cbec69913fc Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:49:27 +0200 Subject: [PATCH 21/24] Suppress solar surplus sensor display during planned car slot --- apps/predbat/execute.py | 17 +++++++--- apps/predbat/tests/test_execute.py | 53 ++++++++++++++++++++++++++++++ 2 files changed, 65 insertions(+), 5 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 2fc4a2c2b..c950a1b8b 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -625,7 +625,13 @@ def execute_plan(self): self.count_inverter_writes[inverter.id] += inverter.count_register_writes inverter.count_register_writes = 0 - # Publish solar surplus car charging binary sensor overrides + # Publish solar surplus car charging binary sensor overrides. + # We track which cars are surplus-eligible AND not already covered by a + # planned slot — those are the ones we display as "active" on the + # observability sensor. The underlying car_charging_solar_surplus_active + # array stays as detect_car_solar_surplus computed it so that + # _car_surplus_prev keeps accurate hysteresis memory across cycles. + displayed_surplus = [] for car_n in range(self.num_cars): if not self.car_charging_solar_surplus_active[car_n]: continue @@ -636,6 +642,7 @@ def execute_plan(self): if self.minutes_now >= window["start"] and self.minutes_now < window["end"] and window.get("kwh", 0) > 0: in_planned_slot = True if not in_planned_slot: + displayed_surplus.append(car_n) # Preserve the planned slot list and totals that publish_car_plan published, just flip state on plan = [] total_cost = 0.0 @@ -668,15 +675,15 @@ def execute_plan(self): }, ) - # Publish solar surplus observability sensor - any_surplus = any(self.car_charging_solar_surplus_active) if self.car_charging_solar_surplus_active else False + # Publish solar surplus observability sensor — reflects "is surplus + # actually driving this car right now", not pure eligibility. self.dashboard_item( "binary_sensor." + self.prefix + "_car_charging_solar_surplus", - state="on" if any_surplus else "off", + state="on" if displayed_surplus else "off", attributes={ "friendly_name": "Predbat car charging on solar surplus", "icon": "mdi:solar-power", - "cars_active": [i for i, a in enumerate(self.car_charging_solar_surplus_active) if a], + "cars_active": displayed_surplus, }, ) diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index c4c8ee9e8..174d16efd 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2822,4 +2822,57 @@ def run_execute_tests(my_predbat): if failed: return failed + # Surplus eligible during a planned slot — eligibility stays True (so hysteresis state + # survives the planned slot ending) but the published cars_active suppresses it + # because the planned slot is what's actually driving the car right now. + failed |= run_solar_surplus_planned_slot_display_test(my_predbat) + if failed: + return failed + + return failed + + +def run_solar_surplus_planned_slot_display_test(my_predbat): + """When a planned slot is active for the same car, the published cars_active + suppresses that car (planned slot is the real driver), but the underlying + eligibility flag stays True so hysteresis memory survives the planned slot ending.""" + print("Run scenario solar_surplus_eligible_during_planned_slot_not_displayed") + my_predbat.log("Run scenario solar_surplus_eligible_during_planned_slot_not_displayed") + my_predbat.inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc)] + my_predbat.args["num_inverters"] = 1 + my_predbat.num_inverters = 1 + + car_slot = [{"start": my_predbat.minutes_now - 10, "end": my_predbat.minutes_now + 50, "kwh": 5.0, "average": 7.0, "cost": 35.0}] + failed = run_execute_test( + my_predbat, + "solar_surplus_eligible_during_planned_slot_not_displayed", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + car_slot=car_slot, + grid_power=7500, + battery_power=0, + car_charging_from_battery=False, + assert_status="Hold for car", + assert_pause_discharge=True, + assert_immediate_soc_target=0, + assert_solar_surplus_active=[True], + ) + + # Inspect the published surplus sensor: cars_active must be empty because the + # planned slot is what's actually driving the car right now. + sensor_id = "binary_sensor." + my_predbat.prefix + "_car_charging_solar_surplus" + sensor = my_predbat.ha_interface.dummy_items.get(sensor_id, {}) + if isinstance(sensor, dict): + cars_active = sensor.get("cars_active", None) + state = sensor.get("state", None) + else: + cars_active, state = None, sensor + if cars_active != []: + print("ERROR: planned-slot suppression — expected cars_active=[], got {}".format(cars_active)) + failed = True + if state != "off": + print("ERROR: planned-slot suppression — expected surplus sensor state='off', got {}".format(state)) + failed = True return failed From e2288c05f871e26a3233212d8e5a7c720a726858 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:49:59 +0200 Subject: [PATCH 22/24] Add multi-car and multi-inverter solar surplus tests --- apps/predbat/tests/test_execute.py | 81 ++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index 174d16efd..810458f86 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2822,6 +2822,17 @@ def run_execute_tests(my_predbat): if failed: return failed + # Multi-car priority — only the first eligible car activates per cycle (loop break). + failed |= run_solar_surplus_multi_car_test(my_predbat) + if failed: + return failed + + # Multi-inverter — the carHolding status accumulates correctly across multiple inverters + # without double-appending (the "Hold for car" not in status substring check). + failed |= run_solar_surplus_multi_inverter_test(my_predbat) + if failed: + return failed + # Surplus eligible during a planned slot — eligibility stays True (so hysteresis state # survives the planned slot ending) but the published cars_active suppresses it # because the planned slot is what's actually driving the car right now. @@ -2832,6 +2843,76 @@ def run_execute_tests(my_predbat): return failed +def run_solar_surplus_multi_car_test(my_predbat): + """With two cars planned, only the first eligible activates (the loop break). + When the first car is ineligible, the second car wins instead.""" + print("Run scenario solar_surplus_multi_car_priority") + my_predbat.log("Run scenario solar_surplus_multi_car_priority") + reset_inverter(my_predbat) + my_predbat.set_read_only = False + my_predbat.num_cars = 2 + my_predbat.car_charging_slots = [[], []] + my_predbat.car_charging_solar_surplus = True + my_predbat.car_charging_solar_surplus_threshold = 500 + my_predbat.car_charging_solar_surplus_limit = 100 + my_predbat.car_charging_solar_surplus_active = [False, False] + my_predbat._car_surplus_prev = [False, False] + my_predbat.car_charging_planned = [True, True] + my_predbat.car_charging_battery_size = [75.0, 75.0] + my_predbat.car_charging_soc = [0, 0] + my_predbat.car_charging_rate = [7.4, 7.4] + my_predbat.grid_power = 7500 + my_predbat.battery_power = 0 + + my_predbat.detect_car_solar_surplus(False) + failed = False + if my_predbat.car_charging_solar_surplus_active != [True, False]: + print("ERROR: multi-car priority — expected [True, False], got {}".format(my_predbat.car_charging_solar_surplus_active)) + failed = True + + # Now make car 0 not eligible, expect car 1 to win. + my_predbat.car_charging_planned = [False, True] + my_predbat._car_surplus_prev = [False, False] + my_predbat.detect_car_solar_surplus(False) + if my_predbat.car_charging_solar_surplus_active != [False, True]: + print("ERROR: multi-car priority fallback — expected [False, True], got {}".format(my_predbat.car_charging_solar_surplus_active)) + failed = True + return failed + + +def run_solar_surplus_multi_inverter_test(my_predbat): + """Status string accumulates correctly across multiple inverters.""" + print("Run scenario solar_surplus_multi_inverter") + my_predbat.log("Run scenario solar_surplus_multi_inverter") + reset_inverter(my_predbat) + inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc), ActiveTestInverter(1, 0, 10.0, my_predbat.now_utc)] + my_predbat.inverters = inverters + my_predbat.args["num_inverters"] = 2 + my_predbat.num_inverters = 2 + + failed = run_execute_test( + my_predbat, + "solar_surplus_multi_inverter", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + grid_power=7500, + battery_power=0, + car_charging_from_battery=False, + assert_status="Hold for car (solar)", + assert_pause_discharge=True, + assert_immediate_soc_target=0, + assert_solar_surplus_active=[True], + ) + + # Restore single-inverter state for any subsequent tests. + my_predbat.inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc)] + my_predbat.args["num_inverters"] = 1 + my_predbat.num_inverters = 1 + return failed + + def run_solar_surplus_planned_slot_display_test(my_predbat): """When a planned slot is active for the same car, the published cars_active suppresses that car (planned slot is the real driver), but the underlying From 80fa3013f67e6ff4bd481cecb9f5fde56eea1376 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Sun, 31 May 2026 09:40:16 +0000 Subject: [PATCH 23/24] Export solar surplus power as a numeric sensor Adds sensor.predbat_car_charging_solar_surplus_power (kW) showing the effective solar export available for car charging. When surplus charging was active last cycle, the car's own consumption is added back to show the true available surplus. Attributes include threshold, battery_power, and surplus_active for use in HA automations that adjust charging current. Addresses PR feedback requesting a numeric sensor so users can build variable-rate charging automations (surplus_kw * 1000 / voltage = amps). --- apps/predbat/execute.py | 16 +++++ apps/predbat/predbat.py | 1 + apps/predbat/tests/test_execute.py | 98 ++++++++++++++++++++++++++++++ docs/car-charging.md | 3 +- 4 files changed, 117 insertions(+), 1 deletion(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index c950a1b8b..67c6674e5 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -35,6 +35,7 @@ class Execute: """ def execute_plan(self): + """Execute the current charge/discharge plan across all inverters.""" status_extra = "" # extra status text added to Predbat notifications status_hold_car = "" # car hold status text status_hold_iboost = "" # iBoost hold status text @@ -686,6 +687,19 @@ def execute_plan(self): "cars_active": displayed_surplus, }, ) + self.dashboard_item( + self.prefix + ".car_charging_solar_surplus_power", + state=dp3(self.car_charging_solar_surplus_power / 1000.0), + attributes={ + "friendly_name": "Predbat solar surplus power", + "state_class": "measurement", + "unit_of_measurement": "kW", + "icon": "mdi:solar-power", + "threshold": self.car_charging_solar_surplus_threshold, + "battery_power": dp3(self.battery_power / 1000.0), + "surplus_active": bool(displayed_surplus), + }, + ) # Set the charge/discharge status information self.set_charge_export_status(isCharging, isExporting, not (isCharging or isExporting)) @@ -712,6 +726,7 @@ def detect_car_solar_surplus(self, in_force_export_window): rather than per inverter. """ self.car_charging_solar_surplus_active = [False] * self.num_cars + self.car_charging_solar_surplus_power = 0 # Skip in read-only mode: we cannot enforce battery-discharge protection, # so don't trigger HA automations that would charge the car unprotected. if not self.car_charging_solar_surplus or self.num_cars <= 0 or in_force_export_window or self.set_read_only: @@ -754,6 +769,7 @@ def detect_car_solar_surplus(self, in_force_export_window): self.car_charging_solar_surplus_active[car_n] = True if self.car_charging_solar_surplus_active[car_n]: + self.car_charging_solar_surplus_power = max(0, effective_export) self.log("Solar surplus car charging active for car {}: export {}W (effective {}W), rate {}W, threshold {}W".format(car_n, int(self.grid_power), int(effective_export), int(car_rate_w), int(threshold))) break # One car at a time from surplus diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index be333404b..0dc43334d 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -601,6 +601,7 @@ def reset(self): self.discharge_rate_now = 0 self.car_charging_hold = False self.car_charging_solar_surplus_active = [] + self.car_charging_solar_surplus_power = 0 self._car_surplus_prev = [] self.car_charging_manual_soc = [] self.car_charging_threshold = 99 diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index 810458f86..216769d1b 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -219,6 +219,7 @@ def run_execute_test( grid_power=0, battery_power=0, assert_solar_surplus_active=None, + assert_solar_surplus_power=None, ): print("Run scenario {}".format(name)) my_predbat.log("Run scenario {}".format(name)) @@ -422,6 +423,13 @@ def run_execute_test( print("ERROR: car_charging_solar_surplus_active should be {} got {}".format(assert_solar_surplus_active, actual)) failed = True + # Validate solar surplus power sensor value + if assert_solar_surplus_power is not None: + actual_power = my_predbat.car_charging_solar_surplus_power + if actual_power != assert_solar_surplus_power: + print("ERROR: car_charging_solar_surplus_power should be {} got {}".format(assert_solar_surplus_power, actual_power)) + failed = True + my_predbat.minutes_now = 12 * 60 return failed @@ -2539,6 +2547,7 @@ def run_execute_tests(my_predbat): assert_pause_discharge=True, assert_immediate_soc_target=0, assert_solar_surplus_active=[True], + assert_solar_surplus_power=7500, ) if failed: return failed @@ -2580,6 +2589,7 @@ def run_execute_tests(my_predbat): battery_power=0, assert_status="Demand", assert_solar_surplus_active=[False], + assert_solar_surplus_power=0, ) if failed: return failed @@ -2700,6 +2710,7 @@ def run_execute_tests(my_predbat): assert_pause_discharge=True, assert_immediate_soc_target=0, assert_solar_surplus_active=[True], + assert_solar_surplus_power=7400, ) if failed: return failed @@ -2840,6 +2851,11 @@ def run_execute_tests(my_predbat): if failed: return failed + # Verify the numeric power sensor is published with correct value and attributes + failed |= run_solar_surplus_power_sensor_test(my_predbat) + if failed: + return failed + return failed @@ -2956,4 +2972,86 @@ def run_solar_surplus_planned_slot_display_test(my_predbat): if state != "off": print("ERROR: planned-slot suppression — expected surplus sensor state='off', got {}".format(state)) failed = True + + # The power sensor should still report real surplus even during a planned slot + power_sensor_id = my_predbat.prefix + ".car_charging_solar_surplus_power" + power_sensor = my_predbat.ha_interface.dummy_items.get(power_sensor_id, {}) + if isinstance(power_sensor, dict): + power_state = power_sensor.get("state", None) + else: + power_state = power_sensor + if power_state != 7.5: + print("ERROR: planned-slot power sensor — expected state=7.5 (kW), got {}".format(power_state)) + failed = True + return failed + + +def run_solar_surplus_power_sensor_test(my_predbat): + """Verify the numeric power sensor is published with correct value and attributes.""" + print("Run scenario solar_surplus_power_sensor") + my_predbat.log("Run scenario solar_surplus_power_sensor") + my_predbat.inverters = [ActiveTestInverter(0, 0, 10.0, my_predbat.now_utc)] + my_predbat.args["num_inverters"] = 1 + my_predbat.num_inverters = 1 + + failed = run_execute_test( + my_predbat, + "solar_surplus_power_sensor_active", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + grid_power=7500, + battery_power=100, + car_charging_from_battery=False, + assert_status="Hold for car (solar)", + assert_pause_discharge=True, + assert_immediate_soc_target=0, + assert_solar_surplus_active=[True], + assert_solar_surplus_power=7500, + ) + + power_sensor_id = my_predbat.prefix + ".car_charging_solar_surplus_power" + power_sensor = my_predbat.ha_interface.dummy_items.get(power_sensor_id, {}) + if not isinstance(power_sensor, dict): + print("ERROR: power sensor not published as dict, got {}".format(type(power_sensor))) + return True + if power_sensor.get("state") != 7.5: + print("ERROR: power sensor state should be 7.5 (kW), got {}".format(power_sensor.get("state"))) + failed = True + if power_sensor.get("unit_of_measurement") != "kW": + print("ERROR: power sensor unit should be 'kW', got {}".format(power_sensor.get("unit_of_measurement"))) + failed = True + if power_sensor.get("state_class") != "measurement": + print("ERROR: power sensor state_class should be 'measurement', got {}".format(power_sensor.get("state_class"))) + failed = True + if power_sensor.get("threshold") != 500: + print("ERROR: power sensor threshold should be 500, got {}".format(power_sensor.get("threshold"))) + failed = True + if power_sensor.get("surplus_active") is not True: + print("ERROR: power sensor surplus_active should be True, got {}".format(power_sensor.get("surplus_active"))) + failed = True + + # Now test with no surplus — sensor should publish 0 + failed |= run_execute_test( + my_predbat, + "solar_surplus_power_sensor_inactive", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + grid_power=3000, + battery_power=0, + assert_status="Demand", + assert_solar_surplus_active=[False], + assert_solar_surplus_power=0, + ) + power_sensor = my_predbat.ha_interface.dummy_items.get(power_sensor_id, {}) + if isinstance(power_sensor, dict): + if power_sensor.get("state") != 0.0: + print("ERROR: power sensor state should be 0.0 when inactive, got {}".format(power_sensor.get("state"))) + failed = True + if power_sensor.get("surplus_active") is not False: + print("ERROR: power sensor surplus_active should be False when inactive, got {}".format(power_sensor.get("surplus_active"))) + failed = True return failed diff --git a/docs/car-charging.md b/docs/car-charging.md index 64e532da2..af6676911 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -586,7 +586,8 @@ Enable the feature with these Predbat entities: ### Sensors - **binary_sensor.predbat_car_charging_slot** gains a `solar_surplus: true` attribute when activated by surplus (rather than a planned charging window). -- **binary_sensor.predbat_car_charging_solar_surplus** — Dedicated sensor showing whether surplus charging is currently active. +- **binary_sensor.predbat_car_charging_solar_surplus** -- Dedicated sensor showing whether surplus charging is currently active. +- **predbat.car_charging_solar_surplus_power** -- Numeric sensor (kW) showing the effective solar surplus power available for car charging. When surplus is active and the car was charging last cycle, this includes the car's own consumption added back to get the true available export. Useful for automations that adjust charging current proportionally (e.g. `surplus_kw * 1000 / 230 = amps`). Attributes include `threshold` (configured shortfall allowance in W), `battery_power` (current home battery power in kW), and `surplus_active` (boolean). ### Interaction with other settings From 41afe3d0bcdca85a43344cf6aabc2345954d4170 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Sun, 31 May 2026 09:57:27 +0000 Subject: [PATCH 24/24] Report solar surplus power even when no car is charging The power sensor now shows effective export whenever the solar surplus feature is enabled, not only when a car qualifies. This lets automations for other surplus loads (iBoost, pool pump) use the same signal. --- apps/predbat/execute.py | 1 + apps/predbat/tests/test_execute.py | 10 +++++----- docs/car-charging.md | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 67c6674e5..5ec120e6c 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -733,6 +733,7 @@ def detect_car_solar_surplus(self, in_force_export_window): self._car_surplus_prev = list(self.car_charging_solar_surplus_active) return + self.car_charging_solar_surplus_power = max(0, self.grid_power) surplus_hysteresis = 200 # W deadband to prevent flapping # Tolerance for transient battery discharge while surplus is asserted. # Distinct from car_charging_solar_surplus_threshold (the user-facing diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index 216769d1b..80ea74be7 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2589,7 +2589,7 @@ def run_execute_tests(my_predbat): battery_power=0, assert_status="Demand", assert_solar_surplus_active=[False], - assert_solar_surplus_power=0, + assert_solar_surplus_power=3000, ) if failed: return failed @@ -3032,7 +3032,7 @@ def run_solar_surplus_power_sensor_test(my_predbat): print("ERROR: power sensor surplus_active should be True, got {}".format(power_sensor.get("surplus_active"))) failed = True - # Now test with no surplus — sensor should publish 0 + # When car doesn't qualify but there's still export, sensor shows raw export failed |= run_execute_test( my_predbat, "solar_surplus_power_sensor_inactive", @@ -3044,12 +3044,12 @@ def run_solar_surplus_power_sensor_test(my_predbat): battery_power=0, assert_status="Demand", assert_solar_surplus_active=[False], - assert_solar_surplus_power=0, + assert_solar_surplus_power=3000, ) power_sensor = my_predbat.ha_interface.dummy_items.get(power_sensor_id, {}) if isinstance(power_sensor, dict): - if power_sensor.get("state") != 0.0: - print("ERROR: power sensor state should be 0.0 when inactive, got {}".format(power_sensor.get("state"))) + if power_sensor.get("state") != 3.0: + print("ERROR: power sensor state should be 3.0 (kW) when car inactive, got {}".format(power_sensor.get("state"))) failed = True if power_sensor.get("surplus_active") is not False: print("ERROR: power sensor surplus_active should be False when inactive, got {}".format(power_sensor.get("surplus_active"))) diff --git a/docs/car-charging.md b/docs/car-charging.md index af6676911..45152814c 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -587,7 +587,7 @@ Enable the feature with these Predbat entities: - **binary_sensor.predbat_car_charging_slot** gains a `solar_surplus: true` attribute when activated by surplus (rather than a planned charging window). - **binary_sensor.predbat_car_charging_solar_surplus** -- Dedicated sensor showing whether surplus charging is currently active. -- **predbat.car_charging_solar_surplus_power** -- Numeric sensor (kW) showing the effective solar surplus power available for car charging. When surplus is active and the car was charging last cycle, this includes the car's own consumption added back to get the true available export. Useful for automations that adjust charging current proportionally (e.g. `surplus_kw * 1000 / 230 = amps`). Attributes include `threshold` (configured shortfall allowance in W), `battery_power` (current home battery power in kW), and `surplus_active` (boolean). +- **predbat.car_charging_solar_surplus_power** -- Numeric sensor (kW) showing the effective solar surplus power available. Always reports current export power when the solar surplus feature is enabled, even if no car is currently charging from surplus. When a car was surplus-charging last cycle, the car's own consumption is added back to show the true available export. Useful for automations that adjust charging current proportionally (e.g. `surplus_kw * 1000 / 230 = amps`) or for driving other solar surplus loads (iBoost, pool pump). Attributes include `threshold` (configured shortfall allowance in W), `battery_power` (current home battery power in kW), and `surplus_active` (boolean). ### Interaction with other settings