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/config.py b/apps/predbat/config.py index 56d62343e..ab098fd32 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -749,6 +749,38 @@ "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_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", + }, { "name": "calculate_export_oncharge", "oldname": "calculate_discharge_oncharge", diff --git a/apps/predbat/execute.py b/apps/predbat/execute.py index 6c7298a92..fe1a2148b 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,38 +432,44 @@ def execute_plan(self): status_freeze_export = " [Freeze exporting]" - # 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): + 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 +621,61 @@ 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: + # 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": 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, + }, + ) + + # 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 @@ -625,6 +691,56 @@ 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 + # 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 + 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 diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 94302b403..e325b9435 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_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/predbat.py b/apps/predbat/predbat.py index cb3402a8a..73d8cec2b 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -595,6 +595,8 @@ 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_surplus_prev = [] self.car_charging_manual_soc = [] self.car_charging_threshold = 99 self.car_charging_energy = {} diff --git a/apps/predbat/tests/test_execute.py b/apps/predbat/tests/test_execute.py index e1ca46f0c..cae760c60 100644 --- a/apps/predbat/tests/test_execute.py +++ b/apps/predbat/tests/test_execute.py @@ -208,6 +208,15 @@ 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_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, ): print("Run scenario {}".format(name)) my_predbat.log("Run scenario {}".format(name)) @@ -293,6 +302,21 @@ 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_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 = 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: + 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 +373,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"] + 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 +409,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 +2416,221 @@ 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_immediate_soc_target=0, + 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=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_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 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", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_solar_surplus_limit=90, + car_charging_planned=[True], + car_battery_size=75.0, + car_soc=60.0, + 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 + # 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", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_solar_surplus_limit=90, + car_charging_planned=[True], + car_battery_size=75.0, + car_soc=67.5, + grid_power=7500, + battery_power=0, + assert_status="Demand", + assert_solar_surplus_active=[False], + ) + if failed: + 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", + set_charge_window=True, + set_export_window=True, + car_charging_solar_surplus=True, + car_charging_planned=[True], + car_battery_size=75.0, + car_soc=74.25, + 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 + + # 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, + "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..2cb8f91ae 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_limit": 100, "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_limit = 100 + 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 diff --git a/docs/car-charging.md b/docs/car-charging.md index 7c11e43f1..1aecbf127 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -552,6 +552,47 @@ 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) 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 + +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. +- **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 + +- **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.