feat(services): add reason codes and schedule comparison details to find services

Add structured reason codes to no-result responses for find_cheapest_block,
find_cheapest_hours, and find_cheapest_schedule. Each handler now classifies
why no result was returned: no_data_in_range, no_intervals_matching_level_filter,
insufficient_intervals_after_filter, or insufficient_contiguous_window.

Add include_comparison_details flag to find_cheapest_schedule. When enabled,
each scheduled task includes a price_comparison field showing the most expensive
alternative window (mean, min, max, start, end) for cost-savings context.

Document stable reason code contracts in en.json service descriptions.
Add corresponding field translations to all locales (de, nb, nl, sv).

Impact: Automations and scripts can now react to why no window was found,
and schedules can display concrete savings vs. worst-case pricing.
This commit is contained in:
Julian Pawlowski 2026-04-12 12:47:11 +00:00
parent 32b080d178
commit c89248d493
10 changed files with 468 additions and 5 deletions

View file

@ -930,6 +930,11 @@ find_cheapest_schedule:
output: output:
collapsed: true collapsed: true
fields: fields:
include_comparison_details:
required: false
default: false
selector:
boolean:
use_base_unit: use_base_unit:
required: false required: false
default: false default: false

View file

@ -126,6 +126,23 @@ def _compute_price_comparison(
return result return result
def _determine_no_window_reason(
price_info: list[dict],
filtered_price_info: list[dict],
duration_intervals: int,
*,
level_filter_active: bool,
) -> str:
"""Classify why no block window could be found."""
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 len(filtered_price_info) < duration_intervals:
return "insufficient_intervals_after_filter"
return "insufficient_contiguous_window"
async def _handle_find_block( # noqa: PLR0915 async def _handle_find_block( # noqa: PLR0915
call: ServiceCall, call: ServiceCall,
*, *,
@ -146,6 +163,7 @@ async def _handle_find_block( # noqa: PLR0915
min_price_level: str | None = call.data.get("min_price_level") min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False) include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile") power_profile: list[int] | None = call.data.get("power_profile")
level_filter_active = min_price_level is not None or max_price_level is not None
duration_minutes_requested = int(duration_td.total_seconds() / 60) duration_minutes_requested = int(duration_td.total_seconds() / 60)
# Round up to nearest quarter-hour interval # Round up to nearest quarter-hour interval
@ -217,9 +235,16 @@ async def _handle_find_block( # noqa: PLR0915
result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse) result = find_cheapest_contiguous_window(filtered_price_info, duration_intervals, reverse=reverse)
if result is None: if result is None:
reason = _determine_no_window_reason(
price_info,
filtered_price_info,
duration_intervals,
level_filter_active=level_filter_active,
)
_LOGGER.info( _LOGGER.info(
"%s: no window found (need %d intervals, have %d after level filter)", "%s: no window found (reason=%s, need %d intervals, have %d after level filter)",
service_label, service_label,
reason,
duration_intervals, duration_intervals,
len(filtered_price_info), len(filtered_price_info),
) )
@ -232,6 +257,7 @@ async def _handle_find_block( # noqa: PLR0915
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"window_found": False, "window_found": False,
"reason": reason,
"window": None, "window": None,
} }

View file

@ -84,6 +84,23 @@ _COMMON_HOURS_SCHEMA = {
FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA) FIND_CHEAPEST_HOURS_SERVICE_SCHEMA = vol.Schema(_COMMON_HOURS_SCHEMA)
def _determine_no_intervals_reason(
price_info: list[dict],
filtered_price_info: list[dict],
total_intervals: int,
*,
level_filter_active: bool,
) -> str:
"""Classify why no interval selection could be found."""
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 len(filtered_price_info) < total_intervals:
return "insufficient_intervals_after_filter"
return "insufficient_intervals_for_constraints"
def _build_found_response( # noqa: PLR0913 def _build_found_response( # noqa: PLR0913
*, *,
result: dict, result: dict,
@ -207,6 +224,7 @@ async def _handle_find_hours(
min_price_level: str | None = call.data.get("min_price_level") min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False) include_comparison_details: bool = call.data.get("include_comparison_details", False)
power_profile: list[int] | None = call.data.get("power_profile") power_profile: list[int] | None = call.data.get("power_profile")
level_filter_active = min_price_level is not None or max_price_level is not None
total_minutes_requested = int(duration_td.total_seconds() / 60) total_minutes_requested = int(duration_td.total_seconds() / 60)
min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES min_segment_minutes_requested = int(min_segment_td.total_seconds() / 60) if min_segment_td else INTERVAL_MINUTES
@ -283,9 +301,16 @@ async def _handle_find_hours(
result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse) result = find_cheapest_n_intervals(filtered_price_info, total_intervals, min_segment_intervals, reverse=reverse)
if result is None: if result is None:
reason = _determine_no_intervals_reason(
price_info,
filtered_price_info,
total_intervals,
level_filter_active=level_filter_active,
)
_LOGGER.info( _LOGGER.info(
"%s: not enough intervals (need %d, have %d after level filter)", "%s: no interval selection found (reason=%s, need %d, have %d after level filter)",
service_label, service_label,
reason,
total_intervals, total_intervals,
len(filtered_price_info), len(filtered_price_info),
) )
@ -300,6 +325,7 @@ async def _handle_find_hours(
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"intervals_found": False, "intervals_found": False,
"reason": reason,
"schedule": None, "schedule": None,
} }

View file

@ -22,6 +22,7 @@ from custom_components.tibber_prices.const import (
) )
from custom_components.tibber_prices.utils.price_window import ( from custom_components.tibber_prices.utils.price_window import (
calculate_window_statistics, calculate_window_statistics,
find_cheapest_contiguous_window,
) )
from homeassistant.exceptions import ServiceValidationError from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers import config_validation as cv from homeassistant.helpers import config_validation as cv
@ -83,11 +84,77 @@ FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA = vol.Schema(
vol.Optional("search_scope"): vol.In(VALID_SEARCH_SCOPES), 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("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("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("use_base_unit", default=False): cv.boolean,
} }
) )
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( def _find_cheapest_window_in_pool(
pool: list[dict[str, Any]], pool: list[dict[str, Any]],
duration_intervals: int, duration_intervals: int,
@ -156,6 +223,8 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
use_base_unit: bool = call.data.get("use_base_unit", False) use_base_unit: bool = call.data.get("use_base_unit", False)
max_price_level: str | None = call.data.get("max_price_level") max_price_level: str | None = call.data.get("max_price_level")
min_price_level: str | None = call.data.get("min_price_level") min_price_level: str | None = call.data.get("min_price_level")
include_comparison_details: bool = call.data.get("include_comparison_details", False)
level_filter_active = min_price_level is not None or max_price_level is not None
# Round gap up to nearest quarter interval # Round gap up to nearest quarter interval
gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0 gap_intervals = math.ceil(gap_minutes / INTERVAL_MINUTES) if gap_minutes > 0 else 0
@ -236,6 +305,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level) filtered_price_info = filter_intervals_by_price_level(price_info, min_price_level, max_price_level)
if not filtered_price_info: if not filtered_price_info:
reason = _determine_schedule_reason(
all_tasks_scheduled=False,
assignments_count=0,
price_info=price_info,
filtered_price_info=filtered_price_info,
level_filter_active=level_filter_active,
)
return { return {
"home_id": home_id, "home_id": home_id,
"search_start": search_start.isoformat(), "search_start": search_start.isoformat(),
@ -243,6 +319,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"all_tasks_scheduled": False, "all_tasks_scheduled": False,
"reason": reason,
"tasks": [], "tasks": [],
"total_estimated_cost": None, "total_estimated_cost": None,
} }
@ -295,6 +372,12 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"duration_minutes": task["duration_minutes"], "duration_minutes": task["duration_minutes"],
**stats, **stats,
"intervals": task_response_intervals, "intervals": task_response_intervals,
"price_comparison": _compute_task_price_comparison(
task_intervals,
price_info,
unit_factor,
include_details=include_comparison_details,
),
} }
) )
@ -308,6 +391,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None total_estimated_cost = round(sum(total_cost_values), 4) if total_cost_values else None
all_scheduled = len(unscheduled) == 0 all_scheduled = len(unscheduled) == 0
reason = _determine_schedule_reason(
all_tasks_scheduled=all_scheduled,
assignments_count=len(assignments),
price_info=price_info,
filtered_price_info=filtered_price_info,
level_filter_active=level_filter_active,
)
_LOGGER.info( _LOGGER.info(
"%s: scheduled %d/%d tasks, total_cost=%s", "%s: scheduled %d/%d tasks, total_cost=%s",
@ -324,6 +414,7 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
"currency": currency, "currency": currency,
"price_unit": price_unit, "price_unit": price_unit,
"all_tasks_scheduled": all_scheduled, "all_tasks_scheduled": all_scheduled,
"reason": reason,
"unscheduled_tasks": unscheduled or None, "unscheduled_tasks": unscheduled or None,
"tasks": assignments, "tasks": assignments,
"total_estimated_cost": total_estimated_cost, "total_estimated_cost": total_estimated_cost,

View file

@ -1847,6 +1847,10 @@
"name": "Minimale Preisstufe", "name": "Minimale Preisstufe",
"description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren." "description": "Nur Intervalle ab dieser Tibber-Preisstufe beruecksichtigen. Nuetzlich fuer find_most_expensive, um wirklich teure Intervalle zu fokussieren."
}, },
"include_comparison_details": {
"name": "Vergleichsdetails einbeziehen",
"description": "Fuegt pro Aufgabe zusaetzliche price_comparison-Details hinzu (comparison_price_min, comparison_price_max, comparison_window_end), um das gefundene Zeitfenster mit dem gegenteiligen Extremfenster gleicher Dauer zu vergleichen."
},
"use_base_unit": { "use_base_unit": {
"name": "Basiswährung verwenden", "name": "Basiswährung verwenden",
"description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen." "description": "Preise in Basiswährung (EUR, NOK) statt der konfigurierten Anzeigeeinheit (ct, øre) erzwingen. Nützlich für Berechnungen."

View file

@ -1387,7 +1387,7 @@
}, },
"find_cheapest_block": { "find_cheapest_block": {
"name": "Find Cheapest Block", "name": "Find Cheapest Block",
"description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics.", "description": "Finds the cheapest contiguous time window of a given duration. Designed for appliance scheduling: dishwasher, washing machine, dryer, etc. Returns the single cheapest window with start/end times and price statistics. If no window is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_contiguous_window).",
"sections": { "sections": {
"search_range": { "search_range": {
"name": "Search Range", "name": "Search Range",
@ -1571,7 +1571,7 @@
}, },
"find_cheapest_hours": { "find_cheapest_hours": {
"name": "Find Cheapest Hours", "name": "Find Cheapest Hours",
"description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments.", "description": "Finds the cheapest intervals totaling a given duration, not necessarily contiguous. Designed for flexible loads: battery charging, EV, water heater. Returns a schedule of intervals grouped into contiguous segments. If no schedule is found, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_intervals_after_filter, insufficient_intervals_for_constraints).",
"sections": { "sections": {
"search_range": { "search_range": {
"name": "Search Range", "name": "Search Range",
@ -1763,7 +1763,7 @@
}, },
"find_cheapest_schedule": { "find_cheapest_schedule": {
"name": "Find Cheapest Schedule", "name": "Find Cheapest Schedule",
"description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats.", "description": "Schedules multiple appliances optimally without time overlap. Each task gets the cheapest available contiguous window; tasks are placed greedily in ascending cost order. Returns a per-task schedule with start/end times and price stats. If scheduling is incomplete, the response includes a stable reason code in the reason field (for example: no_data_in_range, no_intervals_matching_level_filter, insufficient_contiguous_window, insufficient_contiguous_window_for_some_tasks).",
"sections": { "sections": {
"scheduling_options": { "scheduling_options": {
"name": "Scheduling Options", "name": "Scheduling Options",
@ -1847,6 +1847,10 @@
"name": "Minimum Price Level", "name": "Minimum Price Level",
"description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals." "description": "Only consider intervals at or above this Tibber price level. Useful for find_most_expensive to focus on truly expensive intervals."
}, },
"include_comparison_details": {
"name": "Include Comparison Details",
"description": "Add per-task price_comparison details (comparison_price_min, comparison_price_max, comparison_window_end) to compare each selected task window against the opposite extreme window of the same duration."
},
"use_base_unit": { "use_base_unit": {
"name": "Use Base Currency Unit", "name": "Use Base Currency Unit",
"description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations." "description": "Force prices in base currency (EUR, NOK) instead of the configured display unit (ct, øre). Useful for calculations."

View file

@ -1847,6 +1847,10 @@
"name": "Minimalt prisnivaae", "name": "Minimalt prisnivaae",
"description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller." "description": "Ta bare med intervaller paa eller over dette Tibber-prisnivaeet. Nyttig for find_most_expensive for aa fokusere paa virkelig dyre intervaller."
}, },
"include_comparison_details": {
"name": "Inkluder sammenligningsdetaljer",
"description": "Legger til ekstra price_comparison-detaljer per oppgave (comparison_price_min, comparison_price_max, comparison_window_end) for aa sammenligne valgt vindu med motsatt ekstremvindu med samme varighet."
},
"use_base_unit": { "use_base_unit": {
"name": "Bruk basisvaluta", "name": "Bruk basisvaluta",
"description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger." "description": "Tving priser i basisvaluta (EUR, NOK) i stedet for konfigurert visningsenhet (ct, øre). Nyttig for beregninger."

View file

@ -1847,6 +1847,10 @@
"name": "Minimaal prijsniveau", "name": "Minimaal prijsniveau",
"description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen." "description": "Overweeg alleen intervallen op of boven dit Tibber-prijsniveau. Nuttig voor find_most_expensive om te focussen op echt dure intervallen."
}, },
"include_comparison_details": {
"name": "Vergelijkingsdetails opnemen",
"description": "Voegt per taak extra price_comparison-details toe (comparison_price_min, comparison_price_max, comparison_window_end) om het gekozen venster te vergelijken met het tegenovergestelde extreme venster met dezelfde duur."
},
"use_base_unit": { "use_base_unit": {
"name": "Basisvaluta gebruiken", "name": "Basisvaluta gebruiken",
"description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen." "description": "Forceer prijzen in basisvaluta (EUR, NOK) in plaats van de geconfigureerde weergave-eenheid (ct, øre). Handig voor berekeningen."

View file

@ -1847,6 +1847,10 @@
"name": "Minimal prisnivaae", "name": "Minimal prisnivaae",
"description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall." "description": "Ta bara med intervall paa eller oever denna Tibber-prisnivaae. Anvaendbart foer find_most_expensive foer att fokusera paa verkligt dyra intervall."
}, },
"include_comparison_details": {
"name": "Inkludera jaemfoerelsedetaljer",
"description": "Laegger till extra price_comparison-detaljer per uppgift (comparison_price_min, comparison_price_max, comparison_window_end) foer att jaemfoera valt foenster med motsatt extremfoenster med samma laengd."
},
"use_base_unit": { "use_base_unit": {
"name": "Använd basvaluta", "name": "Använd basvaluta",
"description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar." "description": "Tvinga priser i basvaluta (EUR, NOK) istället för konfigurerad visningsenhet (ct, öre). Användbart för beräkningar."

View file

@ -0,0 +1,295 @@
"""Tests for find service response contracts: reason codes and schedule comparison details."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from types import SimpleNamespace
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from homeassistant.core import ServiceCall
import pytest
from custom_components.tibber_prices.services import find_cheapest_block as block_module
from custom_components.tibber_prices.services import find_cheapest_hours as hours_module
from custom_components.tibber_prices.services import find_cheapest_schedule as schedule_module
from custom_components.tibber_prices.services.find_cheapest_block import (
_determine_no_window_reason,
handle_find_cheapest_block,
)
from custom_components.tibber_prices.services.find_cheapest_hours import (
_determine_no_intervals_reason,
handle_find_cheapest_hours,
)
from custom_components.tibber_prices.services.find_cheapest_schedule import (
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
_compute_task_price_comparison,
_determine_schedule_reason,
)
def _make_intervals(prices: list[float], start: datetime | None = None) -> list[dict]:
"""Create quarter-hour intervals for tests."""
base = start or datetime(2026, 1, 1, 0, 0, tzinfo=UTC)
return [
{
"startsAt": (base + timedelta(minutes=15 * i)).isoformat(),
"total": price,
"level": "NORMAL",
}
for i, price in enumerate(prices)
]
class TestBlockNoResultReasons:
"""Reason classification for contiguous block service."""
def test_reason_no_data(self) -> None:
"""Return no_data_in_range when no intervals exist."""
reason = _determine_no_window_reason([], [], 4, level_filter_active=False)
assert reason == "no_data_in_range"
def test_reason_level_filter_eliminated_all(self) -> None:
"""Return level-filter reason when filters remove all intervals."""
reason = _determine_no_window_reason(
_make_intervals([10.0, 12.0]),
[],
4,
level_filter_active=True,
)
assert reason == "no_intervals_matching_level_filter"
def test_reason_not_enough_intervals_after_filter(self) -> None:
"""Return insufficient_intervals_after_filter for short filtered pool."""
reason = _determine_no_window_reason(
_make_intervals([10.0, 12.0, 13.0]),
_make_intervals([10.0, 12.0]),
4,
level_filter_active=False,
)
assert reason == "insufficient_intervals_after_filter"
class TestHoursNoResultReasons:
"""Reason classification for cheapest/most-expensive hours service."""
def test_reason_no_data(self) -> None:
"""Return no_data_in_range when interval pool is empty."""
reason = _determine_no_intervals_reason([], [], 6, level_filter_active=False)
assert reason == "no_data_in_range"
def test_reason_level_filter_eliminated_all(self) -> None:
"""Return level-filter reason when all intervals are filtered out."""
reason = _determine_no_intervals_reason(
_make_intervals([10.0, 11.0]),
[],
4,
level_filter_active=True,
)
assert reason == "no_intervals_matching_level_filter"
def test_reason_not_enough_intervals_after_filter(self) -> None:
"""Return insufficient_intervals_after_filter when pool is too short."""
reason = _determine_no_intervals_reason(
_make_intervals([10.0, 11.0, 12.0]),
_make_intervals([10.0, 11.0]),
4,
level_filter_active=False,
)
assert reason == "insufficient_intervals_after_filter"
class TestScheduleReasonAndComparison:
"""Schedule service reason codes and comparison details behavior."""
def test_schedule_reason_no_data(self) -> None:
"""Return no_data_in_range when schedule has no source intervals."""
reason = _determine_schedule_reason(
all_tasks_scheduled=False,
assignments_count=0,
price_info=[],
filtered_price_info=[],
level_filter_active=False,
)
assert reason == "no_data_in_range"
def test_schedule_reason_level_filter(self) -> None:
"""Return level-filter reason when filter removes all schedule candidates."""
reason = _determine_schedule_reason(
all_tasks_scheduled=False,
assignments_count=0,
price_info=_make_intervals([10.0, 20.0]),
filtered_price_info=[],
level_filter_active=True,
)
assert reason == "no_intervals_matching_level_filter"
def test_schedule_reason_partial(self) -> None:
"""Return partial-schedule reason when some tasks remain unscheduled."""
reason = _determine_schedule_reason(
all_tasks_scheduled=False,
assignments_count=1,
price_info=_make_intervals([10.0, 20.0, 30.0]),
filtered_price_info=_make_intervals([10.0, 20.0, 30.0]),
level_filter_active=False,
)
assert reason == "insufficient_contiguous_window_for_some_tasks"
def test_schedule_schema_accepts_include_comparison_details(self) -> None:
"""Schedule schema should accept include_comparison_details flag."""
result = cast(
"dict[str, Any]",
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA(
{
"tasks": [{"name": "dishwasher", "duration": timedelta(hours=2)}],
"include_comparison_details": True,
}
),
)
assert result["include_comparison_details"] is True
def test_task_comparison_includes_details(self) -> None:
"""Task comparison helper should emit detail fields when enabled."""
full_intervals = _make_intervals([5.0, 10.0, 50.0, 60.0])
task_intervals = full_intervals[:2]
comparison = _compute_task_price_comparison(
task_intervals,
full_intervals,
1,
include_details=True,
)
assert comparison is not None
assert "comparison_price_mean" in comparison
assert "comparison_price_min" in comparison
assert "comparison_price_max" in comparison
assert "comparison_window_start" in comparison
assert "comparison_window_end" in comparison
class _FakePool:
"""Minimal async interval pool for service handler tests."""
def __init__(self, intervals: list[dict]) -> None:
"""Store static interval list returned by get_intervals."""
self._intervals = intervals
async def get_intervals(self, **_kwargs: object) -> tuple[list[dict], bool]:
"""Return predefined intervals and no API-call marker."""
return self._intervals, False
def _build_fake_entry_and_coordinator(intervals: list[dict]) -> tuple[SimpleNamespace, SimpleNamespace, dict]:
"""Build a minimal entry/coordinator/data tuple used by service handlers."""
pool = _FakePool(intervals)
entry = SimpleNamespace(
data={"home_id": "home_1", "currency": "EUR"},
runtime_data=SimpleNamespace(interval_pool=pool),
)
coordinator = SimpleNamespace(
api=object(),
_cached_user_data={"viewer": {"homes": [{"id": "home_1", "timeZone": "UTC"}]}},
)
data = {"priceInfo": intervals}
return entry, coordinator, data
@pytest.mark.asyncio
async def test_block_handler_returns_level_filter_reason(monkeypatch: pytest.MonkeyPatch) -> None:
"""Block handler should return reason when level filter eliminates all intervals."""
intervals = _make_intervals([10.0, 11.0, 12.0, 13.0])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(block_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(block_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
block_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
),
)
call = SimpleNamespace(
hass=object(),
data={
"duration": timedelta(hours=1),
"max_price_level": "very_cheap",
"use_base_unit": True,
},
)
response = cast("dict[str, Any]", await handle_find_cheapest_block(cast("ServiceCall", call)))
assert response["window_found"] is False
assert response["reason"] == "no_intervals_matching_level_filter"
@pytest.mark.asyncio
async def test_hours_handler_returns_insufficient_intervals_reason(monkeypatch: pytest.MonkeyPatch) -> None:
"""Hours handler should return insufficient_intervals_after_filter when pool is too short."""
intervals = _make_intervals([10.0, 11.0, 12.0]) # 3 intervals only
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(hours_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(hours_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
hours_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
),
)
call = SimpleNamespace(
hass=object(),
data={
"duration": timedelta(hours=1), # needs 4 intervals
"use_base_unit": True,
},
)
response = cast("dict[str, Any]", await handle_find_cheapest_hours(cast("ServiceCall", call)))
assert response["intervals_found"] is False
assert response["reason"] == "insufficient_intervals_after_filter"
@pytest.mark.asyncio
async def test_schedule_handler_adds_per_task_comparison_details(monkeypatch: pytest.MonkeyPatch) -> None:
"""Schedule handler should include per-task comparison details when requested."""
intervals = _make_intervals([5.0, 6.0, 50.0, 60.0])
fake_tuple = _build_fake_entry_and_coordinator(intervals)
monkeypatch.setattr(schedule_module, "get_entry_and_data", lambda _hass, _entry_id: fake_tuple)
monkeypatch.setattr(schedule_module, "resolve_home_timezone", lambda _coord, _home_id: "UTC")
monkeypatch.setattr(
schedule_module,
"resolve_search_range",
lambda _call_data, _now, _home_tz: (
datetime(2026, 1, 1, 0, 0, tzinfo=UTC),
datetime(2026, 1, 1, 2, 0, tzinfo=UTC),
),
)
call = SimpleNamespace(
hass=object(),
data={
"tasks": [{"name": "dishwasher", "duration": timedelta(minutes=30)}],
"include_comparison_details": True,
"use_base_unit": True,
},
)
response = cast("dict[str, Any]", await schedule_module.handle_find_cheapest_schedule(cast("ServiceCall", call)))
assert response["all_tasks_scheduled"] is True
assert response["reason"] is None
tasks = cast("list[dict[str, Any]]", response["tasks"])
assert len(tasks) == 1
comparison = cast("dict[str, Any] | None", tasks[0]["price_comparison"])
assert comparison is not None
assert "comparison_price_min" in comparison
assert "comparison_price_max" in comparison
assert "comparison_window_end" in comparison