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
10 changes: 10 additions & 0 deletions apps/predbat/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -990,6 +990,16 @@
"enable": "num_cars",
"enable_condition": "num_cars > 0",
},
{
"name": "car_charging_plan_date",
"friendly_name": "Car charging planned ready date",
"type": "select",
"options": ["Default"],
"icon": "mdi:calendar-end",
"default": "Default",
"enable": "num_cars",
"enable_condition": "num_cars > 0",
},
{
"name": "mode",
"friendly_name": "Predbat mode",
Expand Down
10 changes: 7 additions & 3 deletions apps/predbat/fetch.py
Original file line number Diff line number Diff line change
Expand Up @@ -1849,6 +1849,7 @@ def get_car_charging_planned(self):
self.car_charging_plan_smart = [False for c in range(self.num_cars)]
self.car_charging_plan_max_price = [0 for c in range(self.num_cars)]
self.car_charging_plan_time = ["07:00:00" for c in range(self.num_cars)]
self.car_charging_plan_date = ["Default" for c in range(self.num_cars)]
self.car_charging_battery_size = [100.0 for c in range(self.num_cars)]
self.car_charging_limit = [100.0 for c in range(self.num_cars)]
self.car_charging_rate = [7.4 for c in range(max(self.num_cars, 1))]
Expand Down Expand Up @@ -1884,9 +1885,10 @@ def get_car_charging_planned(self):
self.car_charging_now[car_n] = charging_now

# Other car related configuration
self.car_charging_plan_smart[car_n] = self.get_arg("car_charging_plan_smart", False)
self.car_charging_plan_max_price[car_n] = self.get_arg("car_charging_plan_max_price", 0.0)
self.car_charging_plan_time[car_n] = self.get_arg("car_charging_plan_time", "07:00:00")
self.car_charging_plan_smart[car_n] = self.get_arg("car_charging_plan_smart", False, index=car_n)
self.car_charging_plan_max_price[car_n] = self.get_arg("car_charging_plan_max_price", 0.0, index=car_n)
self.car_charging_plan_time[car_n] = self.get_arg("car_charging_plan_time", "07:00:00", index=car_n)
self.car_charging_plan_date[car_n] = self.get_arg("car_charging_plan_date", "Default", index=car_n)
self.car_charging_battery_size[car_n] = dp2(float(self.get_arg("car_charging_battery_size", 100.0, index=car_n)))
car_postfix = "" if car_n == 0 else "_" + str(car_n)
self.car_charging_rate[car_n] = float(self.get_arg("car_charging_rate" + car_postfix))
Expand Down Expand Up @@ -2335,6 +2337,8 @@ def fetch_config_options(self):
self.manual_freeze_charge_times = self.manual_times("manual_freeze_charge")
self.manual_freeze_export_times = self.manual_times("manual_freeze_export")
self.manual_demand_times = self.manual_times("manual_demand")
if self.num_cars > 0:
self.car_plan_date_options()
self.manual_all_times = self.manual_charge_times + self.manual_export_times + self.manual_demand_times + self.manual_freeze_charge_times + self.manual_freeze_export_times
self.manual_api = self.api_select_update("manual_api")
self.manual_import_rates = self.manual_rates("manual_import_rates", default_rate=self.get_arg("manual_import_value"))
Expand Down
15 changes: 13 additions & 2 deletions apps/predbat/plan.py
Original file line number Diff line number Diff line change
Expand Up @@ -4180,8 +4180,19 @@ def plan_car_charging(self, car_n, low_rates):

ready_minutes = ready_time.hour * 60 + ready_time.minute

# Ready minutes wrap?
if ready_minutes < self.minutes_now:
# Optional ready-by date (multi-day plan window). When the user has selected
# a future date in select.predbat_car_charging_plan_date, anchor the deadline
# to that absolute date plus the time-of-day above. "Default", an empty
# value, or a date <= today fall through to the existing 24-hour wrap.
plan_date_str = self.car_charging_plan_date[car_n] if car_n < len(self.car_charging_plan_date) else "Default"
plan_date = self.parse_car_plan_date(plan_date_str)
today = self.midnight_utc.date()

if plan_date and plan_date > today:
days_offset = (plan_date - today).days
ready_minutes += days_offset * 24 * 60
elif ready_minutes < self.minutes_now:
# Ready minutes wrap?
ready_minutes += 24 * 60

# Car charging now override
Expand Down
196 changes: 196 additions & 0 deletions apps/predbat/tests/test_car_charging_plan_date.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,196 @@
# -----------------------------------------------------------------------------
# Predbat Home Battery System
# Copyright Trefor Southwell 2026 - All Rights Reserved
# This application maybe used for personal use only and not for commercial use
# -----------------------------------------------------------------------------
# fmt off
# pylint: disable=consider-using-f-string
# pylint: disable=line-too-long
# pylint: disable=attribute-defined-outside-init
"""Tests for the multi-day car_charging_plan_date dropdown and plan engine."""
from datetime import timedelta

from tests.test_infra import reset_inverter, reset_rates2


def _format_date(my_predbat, day_offset):
"""Format ``midnight_utc + day_offset`` using the userinterface CAR_PLAN_DATE_FORMAT."""
target = (my_predbat.midnight_utc + timedelta(days=day_offset)).date()
return target.strftime(my_predbat.CAR_PLAN_DATE_FORMAT)


def _setup_single_car(my_predbat, plan_time="07:00:00", plan_date="Default"):
"""Configure a single-car plan_car_charging scenario with shared defaults."""
my_predbat.car_charging_battery_size = [100.0]
my_predbat.car_charging_limit = [100.0]
my_predbat.car_charging_soc = [0.0]
my_predbat.car_charging_soc_next = [None]
my_predbat.car_charging_rate = [10.0]
my_predbat.car_charging_loss = 1.0
my_predbat.car_charging_plan_max_price = [99]
my_predbat.car_charging_plan_smart = [True]
my_predbat.car_charging_plan_time = [plan_time]
my_predbat.car_charging_plan_date = [plan_date]
my_predbat.car_charging_now = [False]
my_predbat.num_cars = 1


def _test_default_preserves_wrap(my_predbat):
"""Default sentinel preserves the existing 24-hour wrap behaviour."""
failed = False
print("**** Running Test: plan_date_default_preserves_wrap ****")
_setup_single_car(my_predbat, plan_time="07:00:00", plan_date="Default")
slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
if not slots:
print("ERROR: Default plan_date should produce charging slots within 24h")
failed = True
return failed


def _test_future_date_extends_window(my_predbat):
"""A plan_date one day in the future doubles the planning window for the car."""
failed = False
print("**** Running Test: plan_date_future_extends_window ****")
_setup_single_car(my_predbat, plan_time="07:00:00", plan_date="Default")
default_slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
default_kwh = sum(slot["kwh"] for slot in default_slots)

_setup_single_car(my_predbat, plan_time="07:00:00", plan_date=_format_date(my_predbat, 1))
future_slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
future_kwh = sum(slot["kwh"] for slot in future_slots)

if future_kwh < default_kwh:
print("ERROR: Future plan_date should not reduce charging energy ({} < {})".format(future_kwh, default_kwh))
failed = True
return failed


def _test_today_date_falls_through(my_predbat):
"""A plan_date of today falls through to the existing wrap (treated as Default)."""
failed = False
print("**** Running Test: plan_date_today_falls_through ****")
_setup_single_car(my_predbat, plan_time="07:00:00", plan_date=_format_date(my_predbat, 0))
today_slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)

_setup_single_car(my_predbat, plan_time="07:00:00", plan_date="Default")
default_slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)

today_kwh = sum(slot["kwh"] for slot in today_slots)
default_kwh = sum(slot["kwh"] for slot in default_slots)
if today_kwh != default_kwh:
print("ERROR: Today plan_date should match Default behaviour ({} != {})".format(today_kwh, default_kwh))
failed = True
return failed


def _test_invalid_date_falls_through(my_predbat):
"""A malformed plan_date string is treated as Default rather than raising."""
failed = False
print("**** Running Test: plan_date_invalid_falls_through ****")
_setup_single_car(my_predbat, plan_time="07:00:00", plan_date="not a date")
parsed = my_predbat.parse_car_plan_date("not a date")
if parsed is not None:
print("ERROR: Invalid plan_date should parse as None, got {}".format(parsed))
failed = True
slots = my_predbat.plan_car_charging(0, my_predbat.low_rates)
if not slots:
print("ERROR: Invalid plan_date should still produce charging slots via fallback")
failed = True
return failed


def _test_stale_past_date_resets_to_default(my_predbat):
"""A previously-selected date that has now passed is reset to Default in the dropdown."""
failed = False
print("**** Running Test: plan_date_stale_past_date_resets_to_default ****")
item = my_predbat.config_index.get("car_charging_plan_date")
if item is None:
print("ERROR: car_charging_plan_date config item missing")
return True

# Simulate a stored value from before today (parses cleanly but is in the past).
yesterday = (my_predbat.midnight_utc - timedelta(days=2)).date()
stale = yesterday.strftime(my_predbat.CAR_PLAN_DATE_FORMAT)
item["value"] = stale

my_predbat.forecast_plan_hours = 96
my_predbat.num_cars = 1
my_predbat.car_plan_date_options()

if item["value"] != my_predbat.CAR_PLAN_DATE_DEFAULT:
print("ERROR: Stale past date should reset to Default, got {}".format(item["value"]))
failed = True
if stale in item["options"]:
print("ERROR: Stale past date should be removed from options after reset, but {} still present".format(stale))
failed = True
return failed


def _test_options_helper_respects_horizon(my_predbat):
"""car_plan_date_options caps the dropdown at min(forecast_plan_hours, 96)//24 days."""
failed = False
print("**** Running Test: plan_date_options_respect_horizon ****")
item = my_predbat.config_index.get("car_charging_plan_date")
if item is None:
print("ERROR: car_charging_plan_date config item missing")
return True

my_predbat.forecast_plan_hours = 24
my_predbat.num_cars = 1
my_predbat.car_plan_date_options()
options_24h = list(item["options"])

my_predbat.forecast_plan_hours = 96
my_predbat.car_plan_date_options()
options_96h = list(item["options"])

if "Default" not in options_24h or "Default" not in options_96h:
print("ERROR: Default sentinel must always be present in options")
failed = True
if len(options_96h) <= len(options_24h):
print("ERROR: 96h horizon should expose more date options than 24h ({} <= {})".format(len(options_96h), len(options_24h)))
failed = True
return failed


def _test_parse_round_trips(my_predbat):
"""Formatted dates round-trip through parse_car_plan_date back to a date."""
failed = False
print("**** Running Test: plan_date_parse_round_trips ****")
for offset in range(0, 4):
formatted = _format_date(my_predbat, offset)
parsed = my_predbat.parse_car_plan_date(formatted)
expected = (my_predbat.midnight_utc + timedelta(days=offset)).date()
if parsed != expected:
print("ERROR: round-trip failed for offset {} ({} -> {} != {})".format(offset, formatted, parsed, expected))
failed = True

if my_predbat.parse_car_plan_date("Default") is not None:
print("ERROR: Default sentinel must parse as None")
failed = True
if my_predbat.parse_car_plan_date("") is not None:
print("ERROR: Empty string must parse as None")
failed = True
return failed


def run_car_charging_plan_date_tests(my_predbat):
"""Run the full car_charging_plan_date test suite."""
failed = False
reset_inverter(my_predbat)

print("**** Running Car Charging Plan Date tests ****")
import_rate = 10.0
export_rate = 5.0
reset_rates2(my_predbat, import_rate, export_rate)
my_predbat.low_rates, _, _ = my_predbat.rate_scan_window(my_predbat.rate_import, 5, my_predbat.rate_import_cost_threshold, False)

failed |= _test_parse_round_trips(my_predbat)
failed |= _test_default_preserves_wrap(my_predbat)
failed |= _test_today_date_falls_through(my_predbat)
failed |= _test_invalid_date_falls_through(my_predbat)
failed |= _test_future_date_extends_window(my_predbat)
failed |= _test_options_helper_respects_horizon(my_predbat)
failed |= _test_stale_past_date_resets_to_default(my_predbat)

return failed
4 changes: 2 additions & 2 deletions apps/predbat/tests/test_fetch_config_options.py
Original file line number Diff line number Diff line change
Expand Up @@ -69,10 +69,10 @@ def mock_get_state_wrapper(entity_id, default=None, attribute=None):
def mock_update_save_restore_list():
pass

# Mock expose_config
# Mock expose_config (accept any kwargs the real implementation uses, e.g. force=True)
exposed_config_calls = []

def mock_expose_config(key, value):
def mock_expose_config(key, value, *args, **kwargs):
exposed_config_calls.append((key, value))

# Apply mocks
Expand Down
2 changes: 2 additions & 0 deletions apps/predbat/unit_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@
from tests.test_nordpool import run_nordpool_test
from tests.test_futurerate_auto import test_futurerate_auto
from tests.test_car_charging_smart import run_car_charging_smart_tests
from tests.test_car_charging_plan_date import run_car_charging_plan_date_tests
from tests.test_plugin_startup import test_plugin_startup_order
from tests.test_optimise_levels import run_optimise_levels_tests
from tests.test_energydataservice import run_energydataservice_tests
Expand Down Expand Up @@ -243,6 +244,7 @@ def main():
("solax", run_solax_tests, "SolaX API tests", False),
("iboost_smart", run_iboost_smart_tests, "iBoost smart tests", False),
("car_charging_smart", run_car_charging_smart_tests, "Car charging smart tests", False),
("car_charging_plan_date", run_car_charging_plan_date_tests, "Car charging plan_date multi-day tests", False),
("intersect_window", run_intersect_window_tests, "Intersect window tests", False),
("inverter_multi", run_inverter_multi_tests, "Inverter multi tests", False),
("octopus_free", test_octopus_free, "Octopus free electricity tests", False),
Expand Down
61 changes: 61 additions & 0 deletions apps/predbat/userinterface.py
Original file line number Diff line number Diff line change
Expand Up @@ -1479,6 +1479,67 @@ def manual_times(self, config_item, exclude=[], new_value=None):
time_txt.append(self.time_abs_str(minute))
return time_overrides

CAR_PLAN_DATE_DEFAULT = "Default"
CAR_PLAN_DATE_FORMAT = "%a %d %b %Y"

def car_plan_date_options(self):
"""
Refresh the car_charging_plan_date dropdown.

Builds the option list for ``select.predbat_car_charging_plan_date`` from
today out to the planning horizon (capped at the 96-hour engine ceiling),
mutates ``item["options"]`` and pushes the result to Home Assistant via
``expose_config``. Mirrors the ``manual_times`` pattern.
"""
item = self.config_index.get("car_charging_plan_date")
if item is None:
return

# Engine horizon ceiling: forecast_plan_hours is itself clamped to forecast_hours
# (see fetch.py self.forecast_plan_hours = max(min(get_arg("forecast_plan_hours"), forecast_hours), 8)),
# so we just read it back and cap at the 96-hour hard ceiling.
plan_hours = self.forecast_plan_hours if hasattr(self, "forecast_plan_hours") else 24
max_days_visible = max(1, min(int(plan_hours), 96) // 24)

today = self.midnight_utc.date()
options = [self.CAR_PLAN_DATE_DEFAULT]
for day_offset in range(max_days_visible + 1):
d = today + timedelta(days=day_offset)
options.append(d.strftime(self.CAR_PLAN_DATE_FORMAT))

# Auto-decay: if the saved selection is a date that has already passed,
# reset to "Default" so the dropdown reflects the user-facing promise that
# "when the selected date passes the entity reverts to Default" rather
# than carrying the stale option forward forever.
current = item.get("value", self.CAR_PLAN_DATE_DEFAULT)
parsed = self.parse_car_plan_date(current)
if parsed and parsed <= today:
current = self.CAR_PLAN_DATE_DEFAULT
elif current and current not in options:
# Future date held over from a previously-larger forecast horizon —
# keep it visible until the user changes it or the date itself decays.
options.append(current)

item["options"] = options
self.expose_config("car_charging_plan_date", current, force=True)

def parse_car_plan_date(self, value):
"""
Parse a car_charging_plan_date option string into a ``datetime.date``.

Returns ``None`` for the "Default" sentinel, an empty string, or any
value that fails to parse. The plan engine treats ``None`` as "fall
through to the existing wrap-around behaviour".
"""
if not value or value == self.CAR_PLAN_DATE_DEFAULT:
return None
try:
from datetime import datetime as _dt

return _dt.strptime(value, self.CAR_PLAN_DATE_FORMAT).date()
except (ValueError, TypeError):
return None

async def update_event(self, event, data, kwargs):
"""
Update event.
Expand Down
5 changes: 5 additions & 0 deletions docs/car-charging.md
Original file line number Diff line number Diff line change
Expand Up @@ -341,6 +341,11 @@ NB2: If you have **car_charging_soc** set and working for your car SoC sensor in

- Set **select.predbat_car_charging_plan_time** to the time you want the car charging to be completed by

- Set **select.predbat_car_charging_plan_date** to pick the day the car needs to be ready by. The default is "Default" which means within the next 24 hours; pick a specific date (e.g. "Fri 09 May 2026") if the car will not be needed for several days. Once the chosen date passes, the entity automatically reverts to "Default".<BR>
This setting only applies to Predbat-managed car charging. With Octopus Intelligent Go, the ready-by date is managed by Octopus and the dropdown has no effect.<BR>
The list of selectable dates comes from your planning horizon, **input_number.predbat_forecast_plan_hours** (*expert mode*). On the default 24-hour horizon you will only see today; raise it (up to 96 hours) to see more days.<BR>
Predbat needs import-rate data to plan ahead. Beyond the rates published by your tariff (typically 24 hours), it assumes the same daily rate pattern repeats. That works well for fixed tariffs and is a best-guess for variable tariffs such as Octopus Agile. See **input_number.predbat_metric_future_rate_offset_import** (*expert mode*) if you want to add pessimism to those assumed rates.

- Turn On **switch.predbat_car_charging_plan_smart** if you want to use the cheapest slots only. When disabled (turned Off) all low-rate slots will be used in time order.
Low-rate slots are time periods where the import rate is below the threshold determined by **input_number.predbat_rate_low_threshold** (*expert mode*).
By default this threshold is calculated automatically based upon future import rates - see [Battery margins and metrics options](customisation.md#battery-margins-and-metrics-options) for details of configuring this threshold.
Expand Down