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..e97359a54 100644 --- a/apps/predbat/execute.py +++ b/apps/predbat/execute.py @@ -53,6 +53,12 @@ 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) + surplus_any = any(self.car_charging_solar_surplus_active) + for inverter in self.inverters: if inverter.id not in self.count_inverter_writes: self.count_inverter_writes[inverter.id] = 0 @@ -427,38 +433,47 @@ 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) + # 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: + 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 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 +625,68 @@ 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. + # 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 + # 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: + 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 + 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 — 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 displayed_surplus else "off", + attributes={ + "friendly_name": "Predbat car charging on solar surplus", + "icon": "mdi:solar-power", + "cars_active": displayed_surplus, + }, + ) + # Set the charge/discharge status information self.set_charge_export_status(isCharging, isExporting, not (isCharging or isExporting)) self.isCharging = isCharging @@ -625,6 +702,63 @@ 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 + # 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 + + 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 + + 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, 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 + 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..808aad58b 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,439 @@ 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, battery idle, 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=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, + 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 + + # 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, + # 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 + + # 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. + failed |= run_solar_surplus_planned_slot_display_test(my_predbat) + if failed: + return failed + + 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 + 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 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..64e532da2 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -552,6 +552,54 @@ 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. + +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: + +- **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.