""" 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, time as dt_time, 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 .entity_resolver import or_entity_ref, resolve_entity_references, resolve_task_entity_references 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" # Parameter types for entity reference resolution _SCHEDULE_ENTITY_PARAMS: dict[str, type] = { "gap_minutes": int, "search_start": datetime, "search_end": datetime, "search_start_time": dt_time, "search_end_time": dt_time, "search_start_day_offset": int, "search_end_day_offset": int, "search_start_offset_minutes": int, "search_end_offset_minutes": int, "must_finish_by": datetime, "duration_flexibility_minutes": int, } _TASK_SCHEMA = vol.Schema( { vol.Required("name"): cv.string, vol.Required("duration"): or_entity_ref( 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): or_entity_ref( vol.All(vol.Coerce(int), vol.Range(min=0, max=120)), ), vol.Optional("search_start"): or_entity_ref(cv.datetime), vol.Optional("search_end"): or_entity_ref(cv.datetime), vol.Optional("search_start_time"): or_entity_ref(cv.time), vol.Optional("search_start_day_offset", default=0): or_entity_ref( vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)), ), vol.Optional("search_end_time"): or_entity_ref(cv.time), vol.Optional("search_end_day_offset", default=0): or_entity_ref( vol.All(vol.Coerce(int), vol.Range(min=-7, max=2)), ), vol.Optional("search_start_offset_minutes"): or_entity_ref( vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)), ), vol.Optional("search_end_offset_minutes"): or_entity_ref( vol.All(vol.Coerce(int), vol.Range(min=-10080, max=10080)), ), vol.Optional("must_finish_by"): or_entity_ref(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("sequential", 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"): or_entity_ref( 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, sequential: bool = False, ) -> tuple[list[dict[str, Any]], list[str], list[dict[str, Any]]]: """Attempt to schedule tasks with specific filter parameters. When sequential=True, tasks are placed in declaration order and each task's search window begins after the previous task's end + gap. When False (default), tasks are sorted longest-first for optimal greedy packing. 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 # Task ordering: declaration order when sequential, longest-first otherwise tasks_ordered = list(tasks) if sequential else sorted(tasks, key=lambda t: t["duration_intervals"], reverse=True) available = [True] * len(search_data) assignments: list[dict[str, Any]] = [] unscheduled: list[str] = [] # In sequential mode, track the earliest allowed start index for the next task sequential_min_idx = 0 sequential_chain_broken = False for task in tasks_ordered: dur_intervals = task["duration_intervals"] # In sequential mode, if the chain is broken (previous task failed), # all remaining tasks are also unscheduled if sequential and sequential_chain_broken: unscheduled.append(task["name"]) continue # In sequential mode, restrict search to intervals at or after the # minimum start index by marking earlier slots as unavailable if sequential and sequential_min_idx > 0: for k in range(min(sequential_min_idx, len(search_data))): available[k] = False window = _find_cheapest_window_in_pool(search_data, dur_intervals, available) if window is None: unscheduled.append(task["name"]) if sequential: sequential_chain_broken = True 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 # In sequential mode, advance the minimum start for the next task if sequential: sequential_min_idx = gap_end assignments.append( { "name": task["name"], "task": task, "intervals": task_intervals, } ) return assignments, unscheduled, filtered def _resolve_schedule_entity_refs( hass: HomeAssistant, call_data: dict[str, Any] | Any, ) -> tuple[dict[str, Any], dict[str, dict[str, str | None]]]: """Resolve entity references in schedule service call data (top-level + tasks).""" data_dict, resolved_refs = resolve_entity_references(hass, call_data, _SCHEDULE_ENTITY_PARAMS) tasks_raw: list[dict[str, Any]] = data_dict["tasks"] resolved_tasks, task_resolved_refs = resolve_task_entity_references(hass, tasks_raw) if resolved_tasks: data_dict["tasks"] = resolved_tasks if task_resolved_refs: resolved_refs.update(task_resolved_refs) return data_dict, resolved_refs async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse: """Handle find_cheapest_schedule service call.""" service_label = "find_cheapest_schedule" hass: HomeAssistant = call.hass # Resolve entity references (top-level params + task durations) data_dict, resolved_refs = _resolve_schedule_entity_refs(hass, call.data) tasks_raw: list[dict[str, Any]] = data_dict["tasks"] entry_id: str = data_dict.get("entry_id", "") gap_minutes: int = data_dict.get("gap_minutes", 0) use_base_unit: bool = data_dict.get("use_base_unit", False) max_price_level: str | None = data_dict.get("max_price_level") min_price_level: str | None = data_dict.get("min_price_level") include_comparison_details: bool = data_dict.get("include_comparison_details", False) smooth_outliers: bool = data_dict.get("smooth_outliers", True) allow_relaxation: bool = data_dict.get("allow_relaxation", True) sequential: bool = data_dict.get("sequential", False) duration_flexibility_minutes: int | None = data_dict.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(data_dict) effective_data, must_finish_by_dt = apply_must_finish_by(data_dict, 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, sequential=%s, range=%s to %s", service_label, len(tasks), gap_minutes, sequential, 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, sequential=sequential, ) 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, sequential=sequential, ) 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, sequential=sequential, ) 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, "sequential": sequential, "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 if resolved_refs: result["_resolved"] = resolved_refs return result