mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
Add `include_current_interval` parameter to `find_cheapest_block` and `find_cheapest_schedule` services, controlling whether the currently active price interval can be the start of the selected window. Add power-profile weighting to `find_cheapest_contiguous_window`: accepts an optional `power_profile` list that weights each interval's price by relative power draw (e.g. heat-up phase heavier than steady state). Without a profile the behaviour is unchanged (uniform weighting). Extend search-range tests and add price-window unit tests covering weighted and unweighted scenarios, edge cases, and sequential scheduling interactions. Update scheduling-actions documentation with parameter and profile examples. Impact: Users can now model appliances with non-uniform power draw (e.g. heat pumps, washing machines) to find truly cheapest windows based on actual energy cost rather than average price.
697 lines
26 KiB
Python
697 lines
26 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, 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("include_current_interval", default=True): cv.boolean,
|
|
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,
|
|
power_profile: list[int] | None = None,
|
|
) -> 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, power_profile=power_profile
|
|
)
|
|
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],
|
|
*,
|
|
power_profile: list[int] | None = None,
|
|
) -> 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.
|
|
power_profile: Optional watt value per interval for weighted scoring.
|
|
Only the first duration_intervals values are used. When provided,
|
|
scoring uses \u03a3 price[i] \u00d7 watt[i] instead of \u03a3 price[i].
|
|
|
|
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:
|
|
if power_profile:
|
|
window_sum = sum(block[k]["total"] * power_profile[k] for k in range(len(block)))
|
|
else:
|
|
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, power_profile=task.get("power_profile")
|
|
)
|
|
|
|
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,
|
|
power_profile=task.get("power_profile"),
|
|
),
|
|
}
|
|
)
|
|
|
|
# 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
|