From 085769934216f81d768ac14d9803bb0c4cf06c1b Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:36:46 +0200 Subject: [PATCH 01/15] 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 56d62343e..f43cfa096 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 a66e69dd750e27c9d604407afaa8e63abca39c0a Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:38:00 +0200 Subject: [PATCH 02/15] 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 cb3402a8a..f8704a305 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -595,6 +595,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 60a8c61904a4e2d53a6266c3cc614408a1c4537e Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:38:08 +0200 Subject: [PATCH 03/15] 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 94302b403..620ffd62c 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -2328,6 +2328,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 33fcd96a37b5a8b86bec08e5492d0cd650b6a447 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:39:17 +0200 Subject: [PATCH 04/15] 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 6c7298a92..ea41d5643 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 9652b35044f2f490ff6e027e80ecdf095ea8ddf5 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:41:16 +0200 Subject: [PATCH 05/15] 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 e4569205c01edfb23a1548edab8566b70203092c Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:44:44 +0200 Subject: [PATCH 06/15] 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 e1ca46f0c..98e9bd042 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -208,6 +208,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)) @@ -293,6 +300,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 @@ -349,7 +367,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: @@ -384,6 +402,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 @@ -2384,4 +2409,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 ee43d11f6..abda816de 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -368,6 +368,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, @@ -475,6 +478,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 a05059726ea6f959a53a59eedfc69ddd07c5b918 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 15:57:15 +0200 Subject: [PATCH 07/15] 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 f31e3bff9..62dfef720 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -74,6 +74,7 @@ dateutil dayname daynumber daysymbol +deadband dedup dend denorm diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index ea41d5643..407e226c4 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 98e9bd042..0bf51c281 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -304,6 +304,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: @@ -367,7 +370,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: @@ -2425,6 +2429,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: @@ -2438,16 +2443,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], ) @@ -2535,6 +2541,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 2aaf7a4422e16bb5810a487bf1bf70c057066462 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 17 Apr 2026 07:24:11 +0200 Subject: [PATCH 08/15] 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 407e226c4..62d301607 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 0d869387885c56d338a0cc82dcc7374623f0d15f Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Sun, 19 Apr 2026 12:43:44 +0200 Subject: [PATCH 09/15] 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 62d301607..f5df21008 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 f8704a305..73d8cec2b 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -596,6 +596,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 80bea67d8a8d5ffbfea6e5dbd4fb3dce199764e0 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 13:04:07 +0200 Subject: [PATCH 10/15] 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 f43cfa096..ab098fd32 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 f5df21008..3553efb68 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 620ffd62c..e325b9435 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -2330,7 +2330,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 0bf51c281..6a49e5fbc 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -210,7 +210,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, @@ -303,7 +303,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) @@ -2508,16 +2508,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", @@ -2526,16 +2547,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 abda816de..2cb8f91ae 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -370,7 +370,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, @@ -480,7 +480,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 cb3081828477684963a5a1c765917f8b91ac7131 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 13:10:58 +0200 Subject: [PATCH 11/15] 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 3553efb68..c227eca84 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 f4471b76963d31d7969ea29001a7664952f2cb13 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 13:11:12 +0200 Subject: [PATCH 12/15] 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 130cac9867858d6c84cfb2fd8686cc907487ba67 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 16:49:45 +0200 Subject: [PATCH 13/15] 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 c227eca84..8011b53a1 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 6a49e5fbc..29648d112 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -212,6 +212,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, @@ -307,6 +308,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: @@ -2509,6 +2511,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", @@ -2517,7 +2520,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, @@ -2530,6 +2534,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", @@ -2538,7 +2543,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", @@ -2548,6 +2554,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", @@ -2555,7 +2562,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 e64ae2bc5a87db116e8c6a02dad6e3211ebbe4c6 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 16:49:47 +0200 Subject: [PATCH 14/15] 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 8011b53a1..fe1a2148b 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 595fd505bb7f2d70b594d4d895f7c8b6784d66b9 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Tue, 21 Apr 2026 17:06:01 +0200 Subject: [PATCH 15/15] 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 29648d112..cae760c60 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -213,6 +213,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, @@ -306,7 +307,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: @@ -2575,6 +2576,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,