Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
162 changes: 139 additions & 23 deletions apps/predbat/execute.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
},
Comment thread
Pezmc marked this conversation as resolved.
Comment thread
Pezmc marked this conversation as resolved.
)

# 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
Expand All @@ -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
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