Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
0857699
Add config items for solar surplus car charging
Pezmc Apr 16, 2026
a66e69d
Initialise solar surplus car charging state
Pezmc Apr 16, 2026
60a8c61
Read solar surplus car charging config options
Pezmc Apr 16, 2026
33fcd96
Add solar surplus car charging detection and battery protection in ex…
Pezmc Apr 16, 2026
9652b35
Add documentation for solar surplus car charging
Pezmc Apr 16, 2026
e456920
Add unit tests for solar surplus car charging
Pezmc Apr 16, 2026
a050597
Fix test assertions and formatting for solar surplus car charging
Pezmc Apr 16, 2026
2aaf7a4
Fix surplus detection running per-inverter and match sensor attribute…
Pezmc Apr 17, 2026
0d86938
Reset _car_surplus_prev in reset() and drop getattr fallback
Pezmc Apr 19, 2026
80bea67
Replace solar surplus ignore_limit switch with a configurable SoC cap
Pezmc Apr 21, 2026
cb30818
Extract solar surplus detection into detect_car_solar_surplus helper
Pezmc Apr 21, 2026
f4471b7
Tweak solar surplus docs wording and line wrapping
Pezmc Apr 21, 2026
130cac9
Fix solar surplus SoC cap to compare kWh-to-kWh
Pezmc Apr 21, 2026
e64ae2b
Preserve planned slot attributes on solar surplus sensor override
Pezmc Apr 21, 2026
595fd50
Add solar surplus hysteresis test coverage for the stay-on branch
Pezmc Apr 21, 2026
396aad1
Document solar surplus prerequisites and battery protection
Pezmc May 8, 2026
f83a24a
Skip solar surplus detection in read-only mode
Pezmc May 8, 2026
2c6ae3a
Stop solar surplus draining home battery on the stay-on branch
Pezmc May 8, 2026
21f7fa0
Pause discharge for solar surplus without car_energy_reported_load
Pezmc May 8, 2026
55bae25
Hoist solar surplus check out of per-inverter loop
Pezmc May 8, 2026
83180c1
Suppress solar surplus sensor display during planned car slot
Pezmc May 8, 2026
97a8dce
Add multi-car and multi-inverter solar surplus tests
Pezmc May 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .cspell/custom-dictionary-workspace.txt
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ dateutil
dayname
daynumber
daysymbol
deadband
dedup
dend
denorm
Expand Down
32 changes: 32 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
},
Comment thread
Pezmc marked this conversation as resolved.
{
"name": "calculate_export_oncharge",
"oldname": "calculate_discharge_oncharge",
Expand Down
182 changes: 158 additions & 24 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
Comment thread
Pezmc marked this conversation as resolved.
Comment thread
Pezmc marked this conversation as resolved.
)

# 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
Expand All @@ -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
Comment thread
Pezmc marked this conversation as resolved.

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
Expand Down
3 changes: 3 additions & 0 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/predbat.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {}
Expand Down
Loading