From 085769934216f81d768ac14d9803bb0c4cf06c1b Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Thu, 16 Apr 2026 14:36:46 +0200 Subject: [PATCH 01/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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/22] 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, From 396aad17c35363e698cdca0506e34b995323fb95 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:45:19 +0200 Subject: [PATCH 16/22] 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 f83a24a323ebd82ff6e5c746c63cd87cc39114df Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:46:12 +0200 Subject: [PATCH 17/22] 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 fe1a2148b..7325961b0 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 cae760c60..1f56fd6a1 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2633,4 +2633,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 2c6ae3af4e70b910fa65f53548f49be924d70026 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:46:58 +0200 Subject: [PATCH 18/22] 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 7325961b0..cc27dacd1 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 1f56fd6a1..f6472b6da 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2579,7 +2579,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", @@ -2589,7 +2589,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 21f7fa0fac7b99144b6346386c90992db3d8f5d0 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:47:35 +0200 Subject: [PATCH 19/22] 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 cc27dacd1..3a0735f4d 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 f6472b6da..4869b57b9 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2672,6 +2672,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 55bae25f72a5d4aa56b559e58cb44e9c4034d1e7 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:48:03 +0200 Subject: [PATCH 20/22] 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 3a0735f4d..2f95f542f 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 83180c110b89ce7c85e8ccfa39b4801ccd4a9731 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:49:27 +0200 Subject: [PATCH 21/22] 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 2f95f542f..e97359a54 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 4869b57b9..c8ab54806 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2717,4 +2717,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 97a8dce348a663bdee5ec436c3e8176461217fa1 Mon Sep 17 00:00:00 2001 From: Pez Cuckow Date: Fri, 8 May 2026 10:49:59 +0200 Subject: [PATCH 22/22] 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 c8ab54806..808aad58b 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -2717,6 +2717,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. @@ -2727,6 +2738,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