diff --git a/.cspell/custom-dictionary-workspace.txt b/.cspell/custom-dictionary-workspace.txt index 4aa094c85..2deeaf9f1 100644 --- a/.cspell/custom-dictionary-workspace.txt +++ b/.cspell/custom-dictionary-workspace.txt @@ -116,6 +116,7 @@ energydataservice energythroughput epod euids +evcc evergen evse exog @@ -207,6 +208,7 @@ linebreak linestyle loadml loadmlpower +loadpoint loadspower localfolder locationless diff --git a/apps/predbat/config.py b/apps/predbat/config.py index ba04a7898..799b699a0 100644 --- a/apps/predbat/config.py +++ b/apps/predbat/config.py @@ -749,6 +749,19 @@ "enable": "num_cars", "enable_condition": "num_cars > 0", }, + { + "name": "car_charging_solar_min_soc", + "friendly_name": "Car charging solar min soc", + "type": "input_number", + "min": 0, + "max": 100, + "step": 5, + "unit": "%", + "icon": "mdi:percent", + "default": 0.0, + "enable": "num_cars", + "enable_condition": "num_cars > 0", + }, { "name": "calculate_export_oncharge", "oldname": "calculate_discharge_oncharge", @@ -2158,6 +2171,12 @@ "car_charging_soc": {"type": "sensor", "sensor_type": "float", "entries": "num_cars"}, "car_charging_limit": {"type": "sensor", "sensor_type": "float", "entries": "num_cars"}, "car_charging_exclusive": {"type": "boolean_list", "entries": "num_cars"}, + "car_charging_solar": {"type": "boolean_list", "entries": "num_cars"}, + "car_charging_plugged": {"type": "sensor|sensor_list", "sensor_type": "string", "entries": "num_cars"}, + "car_charging_solar_max_power": {"type": "sensor|sensor_list", "sensor_type": "float", "entries": "num_cars"}, + "car_charging_solar_min_power": {"type": "sensor|sensor_list", "sensor_type": "float", "entries": "num_cars"}, + "car_charging_solar_power_step": {"type": "sensor|sensor_list", "sensor_type": "float", "entries": "num_cars"}, + "car_charging_solar_limit": {"type": "sensor|sensor_list", "sensor_type": "float", "entries": "num_cars"}, "carbon_intensity": {"type": "sensor", "sensor_type": "string"}, "carbon_postcode": {"type": "string", "empty": False}, "carbon_automatic": {"type": "boolean"}, diff --git a/apps/predbat/fetch.py b/apps/predbat/fetch.py index 41ddb5266..6cc6e5d62 100644 --- a/apps/predbat/fetch.py +++ b/apps/predbat/fetch.py @@ -1106,6 +1106,12 @@ def fetch_sensor_data_car_planning(self): self.log("Car {} on Octopus Intelligent, active plan for charge".format(car_n)) else: self.log("Car {} on Octopus Intelligent, no active plan".format(car_n)) + elif self.car_charging_solar[car_n] and not self.car_charging_planned[car_n]: + # Opportunistic solar charging with no departure plan: do not plan any grid/low-rate slots. + # The PV diversion is modelled directly in the prediction (see prediction.py), so the home + # battery forecast still accounts for the energy the car takes, without scheduling grid charging. + self.car_charging_slots[car_n] = [] + self.log("Car {} solar charging only (no active plan): no grid slots planned, PV diversion handled in the forecast".format(car_n)) elif self.car_charging_planned[car_n] or self.car_charging_now[car_n]: self.log( "Car {} plan charging from {} to {}, with slots {} from SoC {}% to {}%, ready by {}".format( @@ -1915,10 +1921,17 @@ def get_car_charging_planned(self): self.car_charging_rate = [7.4 for c in range(max(self.num_cars, 1))] self.car_charging_slots = [[] for c in range(self.num_cars)] self.car_charging_exclusive = [False for c in range(self.num_cars)] + self.car_charging_solar = [False for c in range(self.num_cars)] + self.car_charging_plugged = [False for c in range(self.num_cars)] + self.car_charging_solar_max_power = [self.car_charging_rate[c] for c in range(self.num_cars)] + self.car_charging_solar_min_power = [0.0 for c in range(self.num_cars)] + self.car_charging_solar_power_step = [0.0 for c in range(self.num_cars)] + self.car_charging_solar_limit = [self.car_charging_limit[c] for c in range(self.num_cars)] self.car_charging_planned_response = self.get_arg("car_charging_planned_response", ["yes", "on", "enable", "true"]) self.car_charging_now_response = self.get_arg("car_charging_now_response", ["yes", "on", "enable", "true"]) self.car_charging_from_battery = self.get_arg("car_charging_from_battery") + self.car_charging_solar_min_soc = self.get_arg("car_charging_solar_min_soc", 0.0) # Car charging planned sensor for car_n in range(self.num_cars): @@ -1954,6 +1967,36 @@ def get_car_charging_planned(self): self.car_charging_limit[car_n] = dp3((float(self.get_arg("car_charging_limit", 100.0, index=car_n)) * self.car_charging_battery_size[car_n]) / 100.0) self.car_charging_exclusive[car_n] = self.get_arg("car_charging_exclusive", False, index=car_n) + # Opportunistic solar (sun-following) charging - models PV diverted to the car by an external charger (e.g. EVCC) + self.car_charging_solar[car_n] = self.get_arg("car_charging_solar", False, index=car_n) + + # Maximum diversion power - defaults to the configured car charging rate, but is uncapped (3-phase chargers exceed the rate slider limit) + self.car_charging_solar_max_power[car_n] = float(self.get_arg("car_charging_solar_max_power", self.car_charging_rate[car_n], index=car_n)) + + # Minimum power needed before the charger will start diverting (e.g. 3-phase 6A) + self.car_charging_solar_min_power[car_n] = float(self.get_arg("car_charging_solar_min_power", 0.0, index=car_n)) + + # Discrete charge power step (kW). Real chargers only switch in whole current steps (e.g. 1A = ~0.69kW on 3-phase), + # so they leave a small surplus remainder. 0 = continuous (no quantisation). + self.car_charging_solar_power_step[car_n] = float(self.get_arg("car_charging_solar_power_step", 0.0, index=car_n)) + + # SoC limit (%) the opportunistic solar diversion fills the car to, independent of the grid plan target + # (car_charging_limit). Defaults to the grid plan target when not configured. Stored in kWh like car_charging_limit. + solar_limit_pct = self.get_arg("car_charging_solar_limit", None, index=car_n) + if solar_limit_pct is None: + self.car_charging_solar_limit[car_n] = self.car_charging_limit[car_n] + else: + self.car_charging_solar_limit[car_n] = dp3((float(solar_limit_pct) * self.car_charging_battery_size[car_n]) / 100.0) + + # Plugged-in status over the forecast horizon - falls back to "charging now" if no dedicated sensor is configured + plugged = self.get_arg("car_charging_plugged", None, index=car_n) + if plugged is None: + self.car_charging_plugged[car_n] = self.car_charging_now[car_n] + elif isinstance(plugged, str): + self.car_charging_plugged[car_n] = plugged.lower() in self.car_charging_now_response + else: + self.car_charging_plugged[car_n] = bool(plugged) + if self.num_cars > 0: self.log( "Cars {} charging from battery {} planned {}, charging_now {} smart {}, max_price {}{}, plan_time {}, battery size {}kWh, limit {}%, rate {}kW, exclusive {}".format( diff --git a/apps/predbat/output.py b/apps/predbat/output.py index 4448f39fe..bbb207c7c 100644 --- a/apps/predbat/output.py +++ b/apps/predbat/output.py @@ -1377,10 +1377,19 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, # Car charging? if self.num_cars > 0: car_charging_kwh = self.car_charge_slot_kwh(minute_start, minute_end) - car_total += car_charging_kwh + # Opportunistic solar diversion modelled in the forecast (cumulative kWh, like iBoost) + car_solar_amount = self.predict_car_solar_best.get(minute_relative_start, 0) + car_solar_amount_end = self.predict_car_solar_best.get(minute_relative_slot_end, car_solar_amount) + car_solar_change = max(car_solar_amount_end - car_solar_amount, 0.0) + car_total += car_charging_kwh + car_solar_change if car_charging_kwh > 0.0: - car_charging_str = str(car_charging_kwh) - car_color = "FFFF00" + # Planned (grid) charging - shown yellow, includes any solar diverted in the same slot + car_charging_str = str(dp2(car_charging_kwh + car_solar_change)) + car_color = "#FFFF00" + elif car_solar_change > 0.0: + # Pure opportunistic solar diversion - shown green + car_charging_str = str(dp2(car_solar_change)) + car_color = "#AEF8A0" else: car_charging_str = "⚊" car_color = "#FFFFFF" @@ -1559,7 +1568,9 @@ def publish_html_plan(self, pv_forecast_minute_step, pv_forecast_minute_step10, json_row["extra_load_total"] = raw_extra_forecast_total json_row["extra_color"] = extra_color if self.num_cars > 0: - json_row["car_charging"] = car_charging_kwh + # Include the modelled opportunistic solar diversion so the JSON/web plan view matches the HTML cell + json_row["car_charging"] = dp2(car_charging_kwh + car_solar_change) + json_row["car_solar"] = dp2(car_solar_change) json_row["car_color"] = car_color if self.iboost_enable: json_row["iboost"] = iboost_amount diff --git a/apps/predbat/plan.py b/apps/predbat/plan.py index 99fc9fcda..6c44fb029 100644 --- a/apps/predbat/plan.py +++ b/apps/predbat/plan.py @@ -3521,6 +3521,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi self.predict_metric_best = pred.predict_metric_best self.predict_carbon_best = pred.predict_carbon_best self.predict_clipped_best = pred.predict_clipped_best + self.predict_car_solar_best = pred.predict_car_solar_best if save: self.log( diff --git a/apps/predbat/predbat.py b/apps/predbat/predbat.py index b297f1e36..a0b4a3fd9 100644 --- a/apps/predbat/predbat.py +++ b/apps/predbat/predbat.py @@ -361,6 +361,7 @@ def reset(self): self.predict_soc = {} self.predict_soc_best = {} self.predict_iboost_best = {} + self.predict_car_solar_best = {} self.predict_metric_best = {} self.metric_min_improvement = 0.0 self.metric_min_improvement_export = 0.1 @@ -457,6 +458,13 @@ def reset(self): self.car_charging_soc_next = [None] self.car_charging_rate = [7.4] self.car_charging_loss = 1.0 + self.car_charging_solar = [False] + self.car_charging_plugged = [False] + self.car_charging_solar_max_power = [7.4] + self.car_charging_solar_min_power = [0.0] + self.car_charging_solar_power_step = [0.0] + self.car_charging_solar_limit = [100.0] + self.car_charging_solar_min_soc = 0.0 self.export_window = [] self.export_limits = [] self.export_limits_best = [] diff --git a/apps/predbat/prediction.py b/apps/predbat/prediction.py index da360363c..431194417 100644 --- a/apps/predbat/prediction.py +++ b/apps/predbat/prediction.py @@ -134,6 +134,13 @@ def __init__(self, base=None, pv_forecast_minute_step=None, pv_forecast_minute10 self.car_charging_slots = base.car_charging_slots self.car_charging_limit = base.car_charging_limit self.car_charging_from_battery = base.car_charging_from_battery + self.car_charging_solar = base.car_charging_solar + self.car_charging_plugged = base.car_charging_plugged + self.car_charging_solar_max_power = base.car_charging_solar_max_power + self.car_charging_solar_min_power = base.car_charging_solar_min_power + self.car_charging_solar_power_step = base.car_charging_solar_power_step + self.car_charging_solar_limit = base.car_charging_solar_limit + self.car_charging_solar_min_soc = base.car_charging_solar_min_soc self.iboost_enable = base.iboost_enable self.iboost_on_export = base.iboost_on_export self.iboost_prevent_discharge = base.iboost_prevent_discharge @@ -417,6 +424,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi self.predict_iboost_best = {} self.predict_carbon_best = {} self.predict_clipped_best = {} + self.predict_car_solar_best = {} self.iboost_running = False self.iboost_running_solar = False self.iboost_running_full = False @@ -488,6 +496,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi iboost_running_solar = self.iboost_running_solar iboost_running_full = self.iboost_running_full car_load_energy_bypass = 0 + car_solar_today = 0 # Remove intersecting windows and optimise the data format of the charge/discharge window charge_limit, charge_window = remove_intersecting_windows(charge_limit, charge_window, export_limits, export_window) @@ -647,6 +656,7 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi self.predict_iboost_best[minute] = round(iboost_today_kwh, 2) self.predict_carbon_best[minute] = round(carbon_g, 0) self.predict_clipped_best[minute] = round(clipped_today, 2) + self.predict_car_solar_best[minute] = round(car_solar_today, 2) else: stamp = "" @@ -678,6 +688,44 @@ def run_prediction(self, charge_limit, charge_window, export_window, export_limi # Simulate car charging if car_enable: + # Opportunistic solar (sun-following) diversion model - applied BEFORE any planned grid charging so + # that free solar is used first and a planned grid top-up only covers the remainder (mirrors EVCC). + # The car takes the PV left after the house load is served (true surplus), once the home battery is + # above the configured priority SoC, capped at its own solar limit (independent of the grid plan + # target). Modelling only - Predbat does not control the car, it only reflects the diverted energy. + for car_n in range(self.num_cars): + if self.car_charging_solar[car_n] and self.car_charging_plugged[car_n] and pv_now > 0 and car_soc[car_n] < self.car_charging_solar_limit[car_n]: + # Home battery priority: only divert to the car once the home battery SoC is above the threshold + if soc_max <= 0 or (soc * 100.0 / soc_max) >= self.car_charging_solar_min_soc: + # Only the PV left after the house load is served is available to the car + surplus = max(pv_now - load_yesterday, 0) + # Available charge power (kW), capped at the maximum diversion power + avail_power = min(surplus * 60.0 / step, self.car_charging_solar_max_power[car_n]) + min_power = self.car_charging_solar_min_power[car_n] + power_step = self.car_charging_solar_power_step[car_n] + if avail_power < min_power: + # Below the charger's minimum start power - nothing is diverted + charge_power = 0 + elif power_step > 0: + # Real chargers only switch in whole current steps (e.g. 1A), so they charge at the + # largest discrete level at or below the surplus, leaving a small remainder to the battery/export + charge_power = min_power + int((avail_power - min_power) / power_step) * power_step + else: + charge_power = avail_power + car_solar_amount = charge_power * step / 60.0 + if car_solar_amount > 0: + # Cap by remaining capacity to the SOLAR limit (battery-side kWh -> PV-side draw via the charging loss) + room = max(self.car_charging_solar_limit[car_n] - car_soc[car_n], 0) + if self.car_charging_loss > 0: + car_solar_amount = min(car_solar_amount, room / self.car_charging_loss) + else: + car_solar_amount = min(car_solar_amount, room) + if car_solar_amount > 0: + pv_now -= car_solar_amount + car_soc[car_n] += car_solar_amount * self.car_charging_loss + car_solar_today += car_solar_amount + + # Planned (grid) car charging - tops up toward the plan target (car_charging_limit), after solar car_load, car_rate_slot = in_car_slot(minute_absolute, self.num_cars, self.car_charging_slots) # Car charging? diff --git a/apps/predbat/tests/test_infra.py b/apps/predbat/tests/test_infra.py index 715b04ea0..f42f22162 100644 --- a/apps/predbat/tests/test_infra.py +++ b/apps/predbat/tests/test_infra.py @@ -566,6 +566,13 @@ def simple_scenario( charge_car=0, car_charging_from_battery=True, car_energy_reported_load=True, + car_charging_solar=False, + car_solar_max_power=7.4, + car_solar_min_power=0.0, + car_solar_power_step=0.0, + car_solar_min_soc=0.0, + car_solar_limit=100.0, + assert_final_car_solar=None, iboost_solar=False, iboost_solar_excess=False, iboost_gas=False, @@ -703,6 +710,15 @@ def simple_scenario( my_predbat.best_soc_keep_weight = keep_weight my_predbat.car_charging_soc[0] = car_soc my_predbat.car_charging_limit[0] = car_limit + my_predbat.car_charging_solar[0] = car_charging_solar + my_predbat.car_charging_plugged[0] = car_charging_solar + my_predbat.car_charging_solar_max_power[0] = car_solar_max_power + my_predbat.car_charging_solar_min_power[0] = car_solar_min_power + my_predbat.car_charging_solar_power_step[0] = car_solar_power_step + my_predbat.car_charging_solar_min_soc = car_solar_min_soc + if car_charging_solar: + # Solar diversion cap (separate from car_charging_limit, the grid plan target) + my_predbat.car_charging_solar_limit[0] = car_solar_limit my_predbat.inverter_can_charge_during_export = inverter_can_charge_during_export my_predbat.charge_scaling10 = charge_scaling10 @@ -734,9 +750,14 @@ def simple_scenario( pv10_step[minute] = pv_amount / (60 / 5) if pv10 else 0 load10_step[minute] = load_amount / (60 / 5) if pv10 else 0 - if charge_car: + if charge_car or car_charging_solar: my_predbat.num_cars = 1 - my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now, "end": my_predbat.forecast_minutes + my_predbat.minutes_now, "kwh": charge_car * my_predbat.forecast_minutes / 60.0}] + if charge_car: + # Planned (grid) charging slot spanning the horizon; combine with car_charging_solar to test coexistence + my_predbat.car_charging_slots[0] = [{"start": my_predbat.minutes_now, "end": my_predbat.forecast_minutes + my_predbat.minutes_now, "kwh": charge_car * my_predbat.forecast_minutes / 60.0}] + else: + # Opportunistic solar car with no planned grid slots, diversion modelled in the prediction + my_predbat.car_charging_slots[0] = [] else: my_predbat.num_cars = 0 my_predbat.car_charging_slots[0] = [] @@ -838,6 +859,13 @@ def simple_scenario( print("ERROR: iBoost running full should be {}".format(assert_iboost_running_full)) failed = True + if assert_final_car_solar is not None: + total_car_solar = prediction.predict_car_solar_best[max(prediction.predict_car_solar_best.keys())] if prediction.predict_car_solar_best else 0 + if abs(total_car_solar - assert_final_car_solar) >= 0.1: + if not ignore_failed: + print("ERROR: Final car solar {} should be {}".format(total_car_solar, assert_final_car_solar)) + failed = True + if save != "none": total_clipped = prediction.predict_clipped_best[max(prediction.predict_clipped_best.keys())] if prediction.predict_clipped_best else 0 if abs(total_clipped - assert_clipped) >= 0.9: diff --git a/apps/predbat/tests/test_model.py b/apps/predbat/tests/test_model.py index 509c71783..d09b3bd7f 100644 --- a/apps/predbat/tests/test_model.py +++ b/apps/predbat/tests/test_model.py @@ -1986,6 +1986,173 @@ def run_model_tests(my_predbat): assert_iboost_running_solar=True, ) + # Opportunistic solar (sun-following) car charging diversion model + reset_rates(my_predbat, import_rate, export_rate) + reset_inverter(my_predbat) + # Baseline: no solar car -> all PV charges the home battery (battery rate caps at 1kW => 24kWh/day) + failed |= simple_scenario("car_solar_off", my_predbat, 0, 1.0, assert_final_metric=0, assert_final_soc=24, with_battery=True, battery_size=100, battery_soc=0, battery_rate_max_charge=1.0) + # Solar car plugged in, capped at 1kW -> all PV diverted off the top to the car, home battery stays empty + failed |= simple_scenario( + "car_solar_divert", + my_predbat, + 0, + 1.0, + assert_final_metric=0, + assert_final_soc=0, + with_battery=True, + battery_size=100, + battery_soc=0, + battery_rate_max_charge=1.0, + car_charging_solar=True, + car_solar_max_power=1.0, + car_solar_limit=100, + assert_final_car_solar=24, + ) + # No PV at night -> nothing diverted, behaves like the baseline-empty case + failed |= simple_scenario( + "car_solar_night", + my_predbat, + 0, + 0, + assert_final_metric=0, + assert_final_soc=0, + with_battery=True, + battery_size=100, + battery_soc=0, + battery_rate_max_charge=1.0, + car_charging_solar=True, + car_solar_max_power=1.0, + car_solar_limit=100, + assert_final_car_solar=0, + ) + # Home battery priority threshold (min_soc 50%) not reached (battery only gets to 24%) -> no diversion + failed |= simple_scenario( + "car_solar_min_soc_block", + my_predbat, + 0, + 1.0, + assert_final_metric=0, + assert_final_soc=24, + with_battery=True, + battery_size=100, + battery_soc=0, + battery_rate_max_charge=1.0, + car_charging_solar=True, + car_solar_max_power=1.0, + car_solar_min_soc=50, + car_solar_limit=100, + assert_final_car_solar=0, + ) + # Charger minimum start power (2kW) not reached by 1kW of surplus -> no diversion + failed |= simple_scenario( + "car_solar_min_power_block", + my_predbat, + 0, + 1.0, + assert_final_metric=0, + assert_final_soc=24, + with_battery=True, + battery_size=100, + battery_soc=0, + battery_rate_max_charge=1.0, + car_charging_solar=True, + car_solar_max_power=7.4, + car_solar_min_power=2.0, + car_solar_limit=100, + assert_final_car_solar=0, + ) + # Limited by remaining car capacity to the limit (10 kWh) -> 10 kWh to car, the rest to the home battery + failed |= simple_scenario( + "car_solar_capacity_cap", + my_predbat, + 0, + 1.0, + assert_final_metric=0, + assert_final_soc=14, + with_battery=True, + battery_size=100, + battery_soc=0, + battery_rate_max_charge=1.0, + car_charging_solar=True, + car_solar_max_power=1.0, + car_solar_limit=10, + assert_final_car_solar=10, + ) + # Only the surplus after the house load is diverted: 2kW PV - 1kW load = 1kW to the car (not the full 2kW) + failed |= simple_scenario( + "car_solar_load_subtracted", + my_predbat, + 1.0, + 2.0, + assert_final_metric=0, + assert_final_soc=0, + with_battery=True, + battery_size=100, + battery_soc=0, + battery_rate_max_charge=1.0, + car_charging_solar=True, + car_solar_max_power=2.0, + car_solar_limit=100, + assert_final_car_solar=24, + ) + # Discrete charge steps (2kW): 9kW surplus -> charges at 8kW (4 + 2*2), leaving 1kW to the home battery. + # The 1kW remainder filling the battery to 24kWh is the robust proof of quantisation (continuous would give 0). + # car_solar reports the start-of-slot cumulative (one PREDICT_STEP behind the final car SoC, like predict_iboost_best). + failed |= simple_scenario( + "car_solar_power_step", + my_predbat, + 0, + 9.0, + assert_final_metric=0, + assert_final_soc=24, + with_battery=True, + battery_size=100, + battery_soc=0, + battery_rate_max_charge=1.0, + car_charging_solar=True, + car_solar_max_power=11.0, + car_solar_min_power=4.0, + car_solar_power_step=2.0, + car_solar_limit=1000, + assert_final_car_solar=191.33, + ) + # Solar limit decoupled from the grid plan target: solar caps at car_solar_limit (60), NOT car_charging_limit (80) + failed |= simple_scenario( + "car_solar_limit_split", + my_predbat, + 0, + 3.0, + assert_final_metric=0, + assert_final_soc=12, + with_battery=True, + battery_size=100, + battery_soc=0, + battery_rate_max_charge=3.0, + inverter_limit=10.0, + car_charging_solar=True, + car_solar_max_power=3.0, + car_limit=80, + car_solar_limit=60, + assert_final_car_solar=60, + ) + # Solar-first ordering: with a planned grid slot AND solar, the surplus is taken before the grid load is added, + # so solar gets the full 0.6kW surplus (=> 14.4 kWh). Grid-first would subtract the 0.3kW car load first (=> 7.2). + failed |= simple_scenario( + "car_solar_first_order", + my_predbat, + 0, + 0.6, + assert_final_metric=import_rate * 7.2, + assert_final_soc=0, + with_battery=False, + charge_car=0.3, + car_charging_solar=True, + car_solar_max_power=0.7, + car_limit=1000, + car_solar_limit=1000, + assert_final_car_solar=14.4, + ) + # PV AC limit tests (AC-coupled / non-hybrid inverters only) reset_rates(my_predbat, import_rate, export_rate) reset_inverter(my_predbat) diff --git a/docs/apps-yaml.md b/docs/apps-yaml.md index 4de2b5a34..8fa96d294 100644 --- a/docs/apps-yaml.md +++ b/docs/apps-yaml.md @@ -1646,6 +1646,12 @@ whether you are within an Octopus Energy "smart charge" slot - **car_charging_battery_size** - Car battery size in kWh - **car_charging_limit** - Percentage limit the car is set to charge to - **car_charging_soc** - Car's current charge level expressed as a percentage +- **car_charging_solar** - Per-car flag to model opportunistic (sun-following) charging done by an external charger such as EVCC; modelling only, no grid charging is planned. See [Opportunistic solar charging](car-charging.md#opportunistic-solar-sun-following-charging) +- **car_charging_plugged** - Optional per-car sensor indicating the car is plugged in over the forecast horizon (falls back to car_charging_now) +- **car_charging_solar_max_power** - Maximum solar diversion power in kW (defaults to the configured car charging rate, uncapped for 3-phase chargers) +- **car_charging_solar_min_power** - Minimum power in kW before the charger will start diverting solar (e.g. 3-phase 6A) +- **car_charging_solar_power_step** - Optional discrete charge-power step in kW (default 0 = continuous); models chargers that only switch in whole current steps (1A ~ 0.69kW on 3-phase) +- **car_charging_solar_limit** - SoC (%) the opportunistic solar charging fills the car to, independent of the grid plan target car_charging_limit (defaults to car_charging_limit). With EVCC this is the loadpoint limit SoC - **ohme_login** - Ohme EV charger account login - **ohme_password** - Password for above Ohme account - **ohme_automatic_octopus_intelligent** - Controls whether Predbat talks directly to the above Ohme account diff --git a/docs/car-charging.md b/docs/car-charging.md index 7c3c6e7f0..776e6bfd4 100644 --- a/docs/car-charging.md +++ b/docs/car-charging.md @@ -392,6 +392,46 @@ mode: single Note: [Multiple cars](car-charging.md#multiple-electric-cars) can be planned with Predbat. +## Opportunistic solar (sun-following) charging + +If you charge your car opportunistically from solar surplus using an **external charger or app** (for example [EVCC](https://evcc.io/) or another solar diverter), +Predbat does not control the car - the external app does. In that situation you do not want Predbat to plan grid/low-rate charging slots for the car, +but you *do* want Predbat's forecast to reflect that the daytime solar surplus is going into the car instead of into the home battery or to export. +Without this, Predbat's home battery forecast is too optimistic on exactly the sunny days when the most solar is diverted to the car. + +The `car_charging_solar` mode models this behaviour. It is **modelling only**: Predbat reflects the diverted energy in its forecast but never commands the car. +It generalises the iBoost solar diversion logic to the car loadpoint, without the iBoost power cap, so it works at the full charge power of a 3-phase charger. + +When enabled for a car: + +- Predbat plans **no** grid/low-rate slots for that car (unless a departure plan is active - see below). +- In the forecast, the car takes the PV surplus that is left **after the house load is served** (it does not steal PV from the house), so the home battery SoC and + export are lower on sunny days, and the modelled car SoC rises towards its limit. +- The diversion is bounded by the available PV surplus, the configured power band, the remaining car capacity up to `car_charging_limit`, and a home battery SoC threshold. + +Configuration (all per-car, set in `apps.yaml` unless noted): + +- **car_charging_solar** - boolean per car (default off). Turns on the solar diversion model and suppresses grid scheduling for that car. +- **car_charging_plugged** - optional sensor per car indicating the car is plugged in across the forecast horizon. If not supplied it falls back to **car_charging_now**. + This is needed because "charging now" says nothing about future daylight slots. +- **car_charging_solar_max_power** - maximum diversion power in kW. Defaults to the configured **car_charging_rate** and is uncapped (3-phase chargers can exceed the rate slider limit). +- **car_charging_solar_min_power** - minimum power in kW before the charger will start diverting (e.g. 3-phase 6A is about 4.1kW). Below this surplus, no solar is diverted. +- **car_charging_solar_power_step** - optional discrete charge-power step in kW (default 0 = continuous). Real chargers only switch in whole current steps + (1A on a 3-phase charger is about 0.69kW), so they charge at the largest discrete level at or below the surplus and leave a small remainder to the home battery/export. + Set this to model that quantisation; leave at 0 to let the car follow the surplus exactly. +- **car_charging_solar_limit** - the SoC (%) the opportunistic solar charging fills the car to, **independent of the grid plan target** (`car_charging_limit`). + Defaults to `car_charging_limit` when not set. With EVCC this is the loadpoint's limit SoC (its PV cap), while `car_charging_limit` is the departure plan target. +- **input_number.predbat_car_charging_solar_min_soc** - home battery SoC threshold (%) in Home Assistant. The car only takes solar once the home battery is above this level, + mirroring EVCC's `priority_soc` so the home battery is charged first. Default 0%. + +On the [Predbat plan](predbat-plan-card.md), solar diverted to the car is shown in the car column in **green** (as opposed to yellow for planned grid charging). + +**Pairing with a departure plan:** if you also set a departure plan for the car (i.e. `car_charging_planned` is active), Predbat's normal planner still runs to guarantee the +departure target, *and* the solar diversion is modelled on top. The two SoC targets are independent: solar fills the car up to `car_charging_solar_limit` (the PV cap), while +the grid plan tops the car up to `car_charging_limit` (the departure target). So you can, for example, let solar charge to 60% opportunistically while a plan still guarantees +80% by departure, or let solar charge to 80% while the plan only guarantees 60% from grid - both are modelled correctly. Solar is always applied **before** the planned grid +charging in each step, so free solar is used first and the grid only covers the remainder, matching how EVCC keeps a plan while still soaking up any remaining solar. + ## Additional Car charging configurations - **switch.predbat_car_charging_from_battery** - When set to On the car can drain the home battery, Predbat will manage the correct level of battery accordingly.