Skip to content

Add solar surplus car charging to divert excess PV to EV#3791

Open
Pezmc wants to merge 7 commits intospringfall2008:mainfrom
Pezmc:feature/car-charging-solar-surplus
Open

Add solar surplus car charging to divert excess PV to EV#3791
Pezmc wants to merge 7 commits intospringfall2008:mainfrom
Pezmc:feature/car-charging-solar-surplus

Conversation

@Pezmc
Copy link
Copy Markdown

@Pezmc Pezmc commented Apr 16, 2026

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_surplus
  • input_number.predbat_car_charging_solar_surplus_threshold
  • switch.predbat_car_charging_solar_surplus_ignore_limit)

Execute plan changes:

  • Surplus detection: compares grid export power against the car's charge rate minus a configurable threshold (default 500W shortfall allowance)
  • Hysteresis: 200W deadband prevents flapping from clouds - higher bar to turn on, lower bar to stay on
  • Effective export: when the car was already surplus-charging last cycle, the car's consumption is added back to grid power to calculate true available surplus
  • Battery protection: surplus charging only activates when the battery isn't discharging, and the existing car_charging_from_battery hold logic applies
  • Force export respected: never activates during planned export windows (since those are earning money)
  • Ignore charge limit: optionally charges past the car's SoC target when using surplus (default: on, since the energy would otherwise be wasted)

The feature sets binary_sensor.predbat_car_charging_slot to ON when surplus is detected, so existing HA automations that watch that sensor work without modification. A dedicated binary_sensor.predbat_car_charging_solar_surplus sensor is also published for observability.

Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread apps/predbat/execute.py
Comment on lines +430 to +462
# 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)
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Suggested change
# 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

Copilot uses AI. Check for mistakes.
Comment thread apps/predbat/execute.py
Comment on lines +665 to +675
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,
},
Copy link

Copilot AI Apr 16, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Using up excess solar to dynamically charge EV

2 participants