Add solar surplus car charging to divert excess PV to EV#3791
Add solar surplus car charging to divert excess PV to EV#3791Pezmc wants to merge 7 commits intospringfall2008:mainfrom
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a core “solar surplus car charging” mode that reuses Predbat’s existing car-charging slot signaling to divert excess PV export into EV charging, with new config controls and observability.
Changes:
- Introduces 3 new config entities to enable/shape surplus-charging behavior (master switch, W threshold, ignore SoC limit).
- Updates
execute_plan()to detect live surplus export with hysteresis and publish surplus-related binary sensors/attributes. - Extends execute/test infrastructure and documentation to cover the new feature.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 2 comments.
Show a summary per file
| File | Description |
|---|---|
| docs/car-charging.md | Documents the new solar surplus charging feature, configuration, and sensors. |
| apps/predbat/config.py | Adds three new configuration items for surplus charging. |
| apps/predbat/fetch.py | Reads the new configuration items into runtime fields. |
| apps/predbat/execute.py | Implements surplus detection, battery-discharge hold integration, and sensor publishing. |
| apps/predbat/predbat.py | Initializes car_charging_solar_surplus_active in reset state. |
| apps/predbat/tests/test_infra.py | Adds defaults/reset fields for the new config/state in tests. |
| apps/predbat/tests/test_execute.py | Adds scenarios validating surplus activation/inhibition rules and status text. |
| .cspell/custom-dictionary-workspace.txt | Adds “deadband” to spelling dictionary. |
| # 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) |
There was a problem hiding this comment.
car_charging_solar_surplus_active is being reset and surplus detection is run inside the per-inverter loop. Since the logic only depends on global totals (self.grid_power, self.battery_power) and not on inverter, this repeats the work/logging once per inverter and can lead to confusing multi-inverter behaviour (e.g., the last inverter iteration “wins” after reinitializing the list). Consider computing surplus activation once outside the inverter loop, then applying the resulting car_charging_solar_surplus_active flags inside the loop for discharge-hold actions.
| # 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) | |
| # Solar surplus car charging - detect excess solar export once for the current execution state | |
| surplus_hysteresis = 200 # W deadband to prevent flapping | |
| was_active = getattr(self, "_car_surplus_prev", [False] * self.num_cars) | |
| surplus_state_key = ( | |
| bool(self.car_charging_solar_surplus), | |
| self.num_cars, | |
| bool(isExporting), | |
| int(self.grid_power), | |
| int(self.battery_power), | |
| bool(self.car_charging_solar_surplus_ignore_limit), | |
| int(self.car_charging_solar_surplus_threshold), | |
| tuple(self.car_charging_planned), | |
| tuple(self.car_charging_soc), | |
| tuple(self.car_charging_limit), | |
| tuple(self.car_charging_rate), | |
| tuple(was_active), | |
| ) | |
| if getattr(self, "_car_surplus_eval_state", None) != surplus_state_key: | |
| self.car_charging_solar_surplus_active = [False] * self.num_cars | |
| if self.car_charging_solar_surplus and self.num_cars > 0 and not isExporting: | |
| 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) | |
| self._car_surplus_eval_state = surplus_state_key |
| 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, | ||
| }, |
There was a problem hiding this comment.
The binary_sensor.*_car_charging_slot override published for solar surplus sets attributes that differ in shape from the normal publish_car_plan() output (e.g., planned becomes a string instead of the usual list, and kWh is used instead of the usual kwh key when the slot is active). This can break existing automations/UI code that expects stable attribute types/keys. Suggest keeping planned as a list (possibly empty) and matching the existing attribute key naming used for the active-slot case.
I've been running a Home Assistant automation that wraps Predbat to divert excess solar to my EV for about a month now and it's been working well, so I'm proposing this as a core feature.
When the home battery is full (or solar exceeds battery charge rate + house load), any surplus is exported to the grid at low feed-in rates. This adds an option to automatically enable car charging during those periods instead, using Predbat's existing car charging infrastructure.
Closes #632, relates to #3316, #3489
Changes
Three new configuration items control the feature. When enabled,
execute_plan()checks live inverter data each cycle.switch.predbat_car_charging_solar_surplusinput_number.predbat_car_charging_solar_surplus_thresholdswitch.predbat_car_charging_solar_surplus_ignore_limit)Execute plan changes:
car_charging_from_batteryhold logic appliesThe feature sets
binary_sensor.predbat_car_charging_slotto ON when surplus is detected, so existing HA automations that watch that sensor work without modification. A dedicatedbinary_sensor.predbat_car_charging_solar_surplussensor is also published for observability.