hass.tibber_prices/custom_components/tibber_prices/services/find_cheapest_schedule.py
Julian Pawlowski 303a7c7835
Some checks are pending
Deploy Docusaurus Documentation (Dual Sites) / Build and Deploy Documentation Sites (push) Waiting to run
Lint / Ruff (push) Waiting to run
Validate / Hassfest validation (push) Waiting to run
Validate / HACS validation (push) Waiting to run
feat(pricing): add relaxation logic for progressive filter loosening
Implement a new service that progressively relaxes user-defined filters to ensure a result is always returned when price data is available. This includes three phases: halving the minimum distance from average, expanding level filters, and reducing duration.

Impact: Users will receive results even when strict filters would otherwise yield no matches, improving the reliability of scheduling actions.

feat(pricing): enhance scheduling actions with new parameters

Introduce new parameters `smooth_outliers`, `min_distance_from_avg`, and `allow_relaxation` to scheduling actions, allowing for better control over price selection and ensuring results are meaningfully different from average prices.

Impact: Users can now fine-tune their scheduling actions to avoid marginal savings and ensure more uniform pricing within selected windows.

docs(scheduling): update documentation for new features

Revise the scheduling actions documentation to include new parameters and their effects, such as outlier smoothing and minimum distance from average, along with examples for better user understanding.

Impact: Users will have clearer guidance on how to utilize new features effectively in their automations.

test(scheduling): add tests for new relaxation logic

Implement unit tests to verify the behavior of the new relaxation logic in scheduling actions, ensuring that filters are correctly relaxed and results are returned as expected.

Impact: Increased test coverage and reliability of the scheduling features.
2026-04-18 21:27:05 +00:00

596 lines
22 KiB
Python

"""
Service handler for find_cheapest_schedule service.
Finds optimal non-overlapping blocks for multiple tasks within a search range.
Uses a greedy algorithm: tasks are sorted by duration (longest first), then
each task claims the cheapest available contiguous window in the remaining pool.
"""
from __future__ import annotations
from datetime import datetime, timedelta
import logging
import math
from typing import TYPE_CHECKING, Any
import voluptuous as vol
from custom_components.tibber_prices.const import DOMAIN, get_display_unit_factor, get_display_unit_string
from custom_components.tibber_prices.utils.price_window import (
calculate_window_statistics,
find_cheapest_contiguous_window,
)
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv
from homeassistant.util import dt as dt_util
from .helpers import (
INTERVAL_MINUTES,
PRICE_LEVEL_ORDER,
VALID_SEARCH_SCOPES,
apply_must_finish_by,
build_rating_lookup,
build_response_interval,
filter_intervals_by_price_level,
get_entry_and_data,
resolve_home_timezone,
resolve_search_range,
restore_original_prices,
smooth_service_intervals,
validate_power_profile_length,
validate_price_level_range,
validate_search_params,
)
from .relaxation import (
MIN_RELAXED_DURATION_INTERVALS,
build_level_filter_steps,
calculate_max_duration_reduction_intervals,
)
if TYPE_CHECKING:
from zoneinfo import ZoneInfo
from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse
_LOGGER = logging.getLogger(__name__)
FIND_CHEAPEST_SCHEDULE_SERVICE_NAME = "find_cheapest_schedule"
_TASK_SCHEMA = vol.Schema(
{
vol.Required("name"): cv.string,
vol.Required("duration"): vol.All(
cv.positive_time_period,
vol.Range(min=timedelta(minutes=1), max=timedelta(hours=12)),
),
vol.Optional("power_profile"): vol.All(
[vol.All(vol.Coerce(int), vol.Range(min=1, max=100000))],
vol.Length(min=1, max=48),
),
}
)
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
{
vol.Optional("entry_id", default=""): cv.string,
vol.Required("tasks"): vol.All(
[_TASK_SCHEMA],
vol.Length(min=1, max=4),
),
vol.Optional("gap_minutes", default=0): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
vol.Optional("search_start"): cv.datetime,
vol.Optional("search_end"): cv.datetime,
vol.Optional("search_start_time"): cv.time,
vol.Optional("search_start_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_end_time"): cv.time,
vol.Optional("search_end_day_offset", default=0): vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)),
vol.Optional("search_start_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("search_end_offset_minutes"): vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)),
vol.Optional("must_finish_by"): cv.datetime,
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES),
vol.Optional("max_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("min_price_level"): vol.In([lvl.lower() for lvl in PRICE_LEVEL_ORDER]),
vol.Optional("include_comparison_details", default=False): cv.boolean,
vol.Optional("use_base_unit", default=False): cv.boolean,
vol.Optional("smooth_outliers", default=True): cv.boolean,
vol.Optional("allow_relaxation", default=True): cv.boolean,
vol.Optional("duration_flexibility_minutes"): vol.All(vol.Coerce(int), vol.Range(min=0, max=120)),
}
)
def _compute_task_price_comparison(
task_intervals: list[dict[str, Any]],
full_price_info: list[dict[str, Any]],
unit_factor: int,
*,
include_details: bool,
) -> dict[str, float | str | None] | None:
"""Compute per-task comparison against most expensive window of same duration."""
duration_intervals = len(task_intervals)
comparison_result = find_cheapest_contiguous_window(full_price_info, duration_intervals, reverse=True)
if comparison_result is None:
return None
task_stats = calculate_window_statistics(task_intervals, unit_factor=unit_factor, round_decimals=4)
comparison_stats = calculate_window_statistics(
comparison_result["intervals"], unit_factor=unit_factor, round_decimals=4
)
task_mean = task_stats.get("price_mean")
comparison_mean = comparison_stats.get("price_mean")
if task_mean is None or comparison_mean is None:
return None
comparison_window_start = comparison_result["intervals"][0]["startsAt"]
if not isinstance(comparison_window_start, str):
comparison_window_start = comparison_window_start.isoformat()
result: dict[str, float | str | None] = {
"comparison_price_mean": comparison_mean,
"price_difference": abs(round(float(comparison_mean) - float(task_mean), 4)),
"comparison_window_start": comparison_window_start,
}
if include_details:
result["comparison_price_min"] = comparison_stats.get("price_min")
result["comparison_price_max"] = comparison_stats.get("price_max")
last_start = comparison_result["intervals"][-1]["startsAt"]
if not isinstance(last_start, str):
last_start = last_start.isoformat()
result["comparison_window_end"] = (
datetime.fromisoformat(last_start) + timedelta(minutes=INTERVAL_MINUTES)
).isoformat()
return result
def _determine_schedule_reason(
*,
all_tasks_scheduled: bool,
assignments_count: int,
price_info: list[dict[str, Any]],
filtered_price_info: list[dict[str, Any]],
level_filter_active: bool,
) -> str | None:
"""Classify schedule outcome reason for automation-friendly no-result handling."""
if all_tasks_scheduled:
return None
if not price_info:
return "no_data_in_range"
if level_filter_active and not filtered_price_info:
return "no_intervals_matching_level_filter"
if assignments_count == 0:
return "insufficient_contiguous_window"
return "insufficient_contiguous_window_for_some_tasks"
def _find_cheapest_window_in_pool(
pool: list[dict[str, Any]],
duration_intervals: int,
available: list[bool],
) -> tuple[int, int] | None:
"""
Find the cheapest contiguous window of `duration_intervals` in available pool slots.
Args:
pool: Full sorted interval list.
duration_intervals: Required contiguous count.
available: Boolean mask, same length as pool. True = still available.
Returns:
(start_index, end_index_exclusive) of the best window, or None if not found.
"""
n = len(pool)
best_sum: float | None = None
best_start: int = -1
i = 0
while i <= n - duration_intervals:
# Check if a contiguous block starting at i is fully available
# and all intervals are contiguous in time (no gaps)
block: list[dict[str, Any]] = []
j = i
while j < n and len(block) < duration_intervals:
if not available[j]:
break
if block:
# Check temporal contiguity
prev_start = block[-1]["startsAt"]
curr_start = pool[j]["startsAt"]
prev_dt = datetime.fromisoformat(prev_start) if isinstance(prev_start, str) else prev_start
curr_dt = datetime.fromisoformat(curr_start) if isinstance(curr_start, str) else curr_start
if curr_dt - prev_dt != timedelta(minutes=INTERVAL_MINUTES):
# Gap in time — can't extend this block, skip to j+1
break
block.append(pool[j])
j += 1
if len(block) == duration_intervals:
window_sum = sum(iv["total"] for iv in block)
if best_sum is None or window_sum < best_sum:
best_sum = window_sum
best_start = i
i += 1
else:
# Skip past the blocking unavailable/non-contiguous slot
i = j + 1
if best_start == -1:
return None
return (best_start, best_start + duration_intervals)
def _attempt_schedule(
price_info: list[dict[str, Any]],
*,
max_price_level: str | None,
min_price_level: str | None,
tasks: list[dict[str, Any]],
gap_intervals: int,
smooth_outliers: bool,
) -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]]]:
"""Attempt to schedule tasks with specific filter parameters.
Returns:
(assignments, unscheduled_names, filtered_price_info)
"""
filtered = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
if smooth_outliers and filtered:
search_data = smooth_service_intervals(filtered)
else:
search_data = filtered
if not search_data:
return [], [t["name"] for t in tasks], filtered
# Greedy assignment: longest task first
tasks_sorted = sorted(tasks, key=lambda t: t["duration_intervals"], reverse=True)
available = [True] * len(search_data)
assignments: list[dict[str, Any]] = []
unscheduled: list[str] = []
for task in tasks_sorted:
dur_intervals = task["duration_intervals"]
window = _find_cheapest_window_in_pool(search_data, dur_intervals, available)
if window is None:
unscheduled.append(task["name"])
continue
start_idx, end_idx = window
task_intervals = search_data[start_idx:end_idx]
# Restore original prices for response
if smooth_outliers:
task_intervals = restore_original_prices(task_intervals)
# Mark task intervals + trailing gap as unavailable
gap_end = min(end_idx + gap_intervals, len(search_data))
for k in range(start_idx, gap_end):
available[k] = False
assignments.append(
{
"name": task["name"],
"task": task,
"intervals": task_intervals,
}
)
return assignments, unscheduled, filtered
async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"""Handle find_cheapest_schedule service call."""
service_label = "find_cheapest_schedule"
hass: HomeAssistant = call.hass
entry_id: str = call.data.get("entry_id", "")
tasks_raw: list[dict[str, Any]] = call.data["tasks"]
gap_minutes: int = call.data.get("gap_minutes", 0)
use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False)
smooth_outliers: bool = call.data.get("smooth_outliers", True)
allow_relaxation: bool = call.data.get("allow_relaxation", True)
duration_flexibility_minutes: int | None = call.data.get("duration_flexibility_minutes")
# Validate task names are unique (before any expensive operations)
task_names = [t["name"] for t in tasks_raw]
duplicate_names = sorted({n for n in task_names if task_names.count(n) > 1})
if duplicate_names:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="duplicate_task_names",
translation_placeholders={"names": ", ".join(duplicate_names)},
)
# Round gap up to nearest quarter interval
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
entry, coordinator, data = get_entry_and_data(hass, entry_id)
rating_lookup = build_rating_lookup(data)
home_id = entry.data.get("home_id")
if not home_id:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="missing_home_id",
)
home_timezone = resolve_home_timezone(coordinator, home_id)
home_tz: ZoneInfo
from zoneinfo import ZoneInfo # noqa: PLC0415
home_tz = ZoneInfo(home_timezone)
# Validate and handle must_finish_by
validate_search_params(call.data)
effective_data, must_finish_by_dt = apply_must_finish_by(call.data, home_tz)
now = dt_util.now().astimezone(home_tz)
search_start, search_end = resolve_search_range(effective_data, now, home_tz)
# Resolve task durations (round up to intervals)
tasks: list[dict[str, Any]] = []
for task in tasks_raw:
dur_td: timedelta = task["duration"]
dur_minutes_req = int(dur_td.total_seconds() / 60)
dur_minutes = math.ceil(dur_minutes_req / INTERVAL_MINUTES) * INTERVAL_MINUTES
dur_intervals = dur_minutes // INTERVAL_MINUTES
validate_power_profile_length(task.get("power_profile"), dur_intervals)
tasks.append(
{
"name": task["name"],
"duration_minutes_requested": dur_minutes_req,
"duration_minutes": dur_minutes,
"duration_intervals": dur_intervals,
"power_profile": task.get("power_profile"),
}
)
# Validate parameter combinations
validate_price_level_range(min_price_level, max_price_level)
# Validate that total task time + gaps fits within the search window
window_minutes = int((search_end - search_start).total_seconds() / 60)
total_task_minutes = sum(t["duration_minutes"] for t in tasks)
total_gap_minutes = gap_intervals * INTERVAL_MINUTES * max(0, len(tasks) - 1)
required_minutes = total_task_minutes + total_gap_minutes
if required_minutes > window_minutes:
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="tasks_exceed_search_window",
translation_placeholders={
"total_minutes": str(required_minutes),
"window_minutes": str(window_minutes),
},
)
_LOGGER.info(
"%s called: %d tasks, gap=%dmin, range=%s to %s",
service_label,
len(tasks),
gap_minutes,
search_start,
search_end,
)
# Fetch intervals
api_client = coordinator.api
user_data = coordinator._cached_user_data # noqa: SLF001
pool = entry.runtime_data.interval_pool
try:
price_info, _api_called = await pool.get_intervals(
api_client=api_client,
user_data=user_data,
start_time=search_start,
end_time=search_end,
)
except Exception as error:
_LOGGER.exception("Error fetching price data for %s", service_label)
raise ServiceValidationError(
translation_domain=DOMAIN,
translation_key="price_fetch_failed",
) from error
currency = entry.data.get("currency", "EUR")
unit_factor = 1 if use_base_unit else get_display_unit_factor(entry)
price_unit = f"{currency}/kWh" if use_base_unit else get_display_unit_string(entry, currency)
# --- Attempt with original parameters ---
raw_assignments, unscheduled, filtered = _attempt_schedule(
price_info,
max_price_level=max_price_level,
min_price_level=min_price_level,
tasks=tasks,
gap_intervals=gap_intervals,
smooth_outliers=smooth_outliers,
)
all_scheduled = len(unscheduled) == 0
level_filter_active = min_price_level is not None or max_price_level is not None
relaxation_applied = False
relaxation_steps = 0
# --- Relaxation loop ---
if not all_scheduled and allow_relaxation:
step_num = 0
best_assignments = raw_assignments
best_unscheduled = unscheduled
best_filtered = filtered
best_step = 0
# Phase 1: Level filter relaxation
level_steps = build_level_filter_steps(max_price_level, min_price_level, reverse=False)
for lvl_max, lvl_min in level_steps:
step_num += 1
a, u, f = _attempt_schedule(
price_info,
max_price_level=lvl_max,
min_price_level=lvl_min,
tasks=tasks,
gap_intervals=gap_intervals,
smooth_outliers=smooth_outliers,
)
if len(a) > len(best_assignments):
best_assignments, best_unscheduled, best_filtered = a, u, f
best_step = step_num
if not u:
break
# Phase 2: Duration reduction (uniform across all tasks)
if best_unscheduled:
shortest_task = min(t["duration_intervals"] for t in tasks)
max_reduction = calculate_max_duration_reduction_intervals(shortest_task, duration_flexibility_minutes)
for reduction in range(1, max_reduction + 1):
# Build reduced-duration tasks
reduced = []
can_reduce = True
for t in tasks:
new_dur = t["duration_intervals"] - reduction
if new_dur < MIN_RELAXED_DURATION_INTERVALS:
can_reduce = False
break
reduced.append(
{
**t,
"duration_intervals": new_dur,
"duration_minutes": new_dur * INTERVAL_MINUTES,
}
)
if not can_reduce:
break
step_num += 1
a, u, f = _attempt_schedule(
price_info,
max_price_level=None,
min_price_level=None,
tasks=reduced,
gap_intervals=gap_intervals,
smooth_outliers=smooth_outliers,
)
if len(a) > len(best_assignments):
best_assignments, best_unscheduled, best_filtered = a, u, f
best_step = step_num
if not u:
break
if best_step > 0 and len(best_assignments) > len(raw_assignments):
raw_assignments = best_assignments
unscheduled = best_unscheduled
filtered = best_filtered
relaxation_applied = True
relaxation_steps = best_step
all_scheduled = len(unscheduled) == 0
_LOGGER.info(
"%s: relaxation improved result at step %d (%d/%d tasks)",
service_label,
best_step,
len(raw_assignments),
len(tasks),
)
elif not unscheduled:
# All scheduled via relaxation at best_step
raw_assignments = best_assignments
unscheduled = best_unscheduled
filtered = best_filtered
relaxation_applied = True
relaxation_steps = best_step
all_scheduled = True
# --- Build full response assignments from raw ---
assignments: list[dict[str, Any]] = []
for raw in raw_assignments:
task_intervals = raw["intervals"]
task = raw["task"]
stats = calculate_window_statistics(
task_intervals,
unit_factor=unit_factor,
round_decimals=4,
power_profile=task.get("power_profile"),
)
first_start = task_intervals[0]["startsAt"]
last_start = task_intervals[-1]["startsAt"]
first_dt = datetime.fromisoformat(first_start) if isinstance(first_start, str) else first_start
last_dt = datetime.fromisoformat(last_start) if isinstance(last_start, str) else last_start
end_dt = last_dt + timedelta(minutes=INTERVAL_MINUTES)
task_response_intervals = [build_response_interval(iv, unit_factor, rating_lookup) for iv in task_intervals]
assignments.append(
{
"name": task["name"],
"start": first_dt.isoformat(),
"end": end_dt.isoformat(),
"duration_minutes_requested": task["duration_minutes_requested"],
"duration_minutes": task["duration_minutes"],
**stats,
"intervals": task_response_intervals,
"price_comparison": _compute_task_price_comparison(
task_intervals,
price_info,
unit_factor,
include_details=include_comparison_details,
),
}
)
# Sort final assignments by start time
assignments.sort(key=lambda a: a["start"])
# Sum estimated costs
total_cost_values: list[float] = [
a["estimated_total_cost"] for a in assignments if a.get("estimated_total_cost") is not None
]
total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None
reason = _determine_schedule_reason(
all_tasks_scheduled=all_scheduled,
assignments_count=len(assignments),
price_info=price_info,
filtered_price_info=filtered,
level_filter_active=level_filter_active,
)
if not all_scheduled and relaxation_applied and reason is not None:
reason = "relaxation_exhausted"
_LOGGER.info(
"%s: scheduled %d/%d tasks, total_cost=%s",
service_label,
len(assignments),
len(tasks),
total_estimated_cost,
)
# Compute seconds_until_start and seconds_until_end for each scheduled task
for task_entry in assignments:
task_start_dt = datetime.fromisoformat(task_entry["start"])
task_entry["seconds_until_start"] = max(0, int((task_start_dt - now).total_seconds()))
task_end_dt = datetime.fromisoformat(task_entry["end"])
task_entry["seconds_until_end"] = max(0, int((task_end_dt - now).total_seconds()))
result: dict[str, Any] = {
"home_id": home_id,
"search_start": search_start.isoformat(),
"search_end": search_end.isoformat(),
"must_finish_by": must_finish_by_dt.isoformat() if must_finish_by_dt else None,
"currency": currency,
"price_unit": price_unit,
"all_tasks_scheduled": all_scheduled,
"reason": reason,
"relaxation_applied": relaxation_applied,
"unscheduled_tasks": unscheduled or None,
"tasks": assignments,
"total_estimated_cost": total_estimated_cost,
}
if relaxation_applied:
result["relaxation_steps"] = relaxation_steps
return result