hass.tibber_prices/tests/services/test_sequential_scheduling.py
Julian Pawlowski 31fca73ccd feat(services): add sequential parameter to find_cheapest_schedule
When sequential: true, tasks are placed in declaration order instead of
being sorted by duration. Each task's search window starts after the
previous task ends (plus gap_minutes). If a task cannot be placed, all
subsequent tasks in the chain are also marked unscheduled.

Adds 12 tests covering ordering, chaining, gap enforcement, and
chain-breaking behavior.

Impact: Users can now schedule dependent appliances (e.g., washing
machine → dryer) in a single find_cheapest_schedule call with guaranteed
order, instead of chaining two find_cheapest_block calls.
2026-04-19 14:17:32 +00:00

355 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Tests for sequential scheduling feature in find_cheapest_schedule."""
from __future__ import annotations
from datetime import UTC, datetime, timedelta
from typing import Any, cast
from custom_components.tibber_prices.services.find_cheapest_schedule import (
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA,
_attempt_schedule,
)
def _make_intervals(
prices: list[float],
start: datetime | None = None,
*,
level: str = "NORMAL",
) -> list[dict[str, Any]]:
"""Create contiguous 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": level,
}
for i, price in enumerate(prices)
]
def _make_tasks(*specs: tuple[str, int]) -> list[dict[str, Any]]:
"""Create task dicts from (name, duration_intervals) tuples."""
return [
{
"name": name,
"duration_minutes_requested": dur * 15,
"duration_minutes": dur * 15,
"duration_intervals": dur,
"power_profile": None,
}
for name, dur in specs
]
class TestSequentialSchema:
"""Schema accepts sequential parameter."""
def test_schema_accepts_sequential_true(self) -> None:
"""Schema should accept sequential: true."""
result = cast(
"dict[str, Any]",
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA(
{
"tasks": [{"name": "dishwasher", "duration": timedelta(hours=1)}],
"sequential": True,
}
),
)
assert result["sequential"] is True
def test_schema_defaults_sequential_false(self) -> None:
"""Sequential should default to false when omitted."""
result = cast(
"dict[str, Any]",
FIND_CHEAPEST_SCHEDULE_SERVICE_SCHEMA(
{
"tasks": [{"name": "dishwasher", "duration": timedelta(hours=1)}],
}
),
)
assert result["sequential"] is False
class TestSequentialOrdering:
"""Sequential mode preserves declaration order and chains search windows."""
def test_non_sequential_sorts_by_duration(self) -> None:
"""Default (non-sequential) mode sorts tasks longest-first."""
# 16 intervals = 4 hours of data
# Prices: first 8 cheap, last 8 expensive
prices = [5.0] * 8 + [20.0] * 8
pool = _make_intervals(prices)
# Task A is short (2 intervals), Task B is long (4 intervals)
tasks = _make_tasks(("short_a", 2), ("long_b", 4))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=False,
)
assert not unscheduled
assert len(assignments) == 2
# Greedy longest-first: long_b gets placed first (cheapest window)
# Assignments are returned in placement order (longest first)
assert assignments[0]["name"] == "long_b"
assert assignments[1]["name"] == "short_a"
def test_sequential_preserves_declaration_order(self) -> None:
"""Sequential mode places tasks in the order they appear."""
# 16 intervals, all same price
prices = [10.0] * 16
pool = _make_intervals(prices)
# Declare short task first, long task second
tasks = _make_tasks(("short_a", 2), ("long_b", 4))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 2
# Sequential: short_a placed first, long_b after
assert assignments[0]["name"] == "short_a"
assert assignments[1]["name"] == "long_b"
def test_sequential_chains_search_windows(self) -> None:
"""Each sequential task starts after the previous task's end."""
# 12 intervals: first 4 are cheap, next 4 medium, last 4 expensive
prices = [5.0] * 4 + [10.0] * 4 + [20.0] * 4
pool = _make_intervals(prices)
# Two tasks of 3 intervals each
tasks = _make_tasks(("task_a", 3), ("task_b", 3))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 2
# Task A should get the cheapest window (intervals 0-2)
a_end_last = datetime.fromisoformat(assignments[0]["intervals"][-1]["startsAt"])
# Task B must start at or after task A's end
b_start = datetime.fromisoformat(assignments[1]["intervals"][0]["startsAt"])
assert b_start >= a_end_last + timedelta(minutes=15)
def test_sequential_respects_gap(self) -> None:
"""Sequential mode enforces gap between tasks."""
# 16 intervals of uniform price
prices = [10.0] * 16
pool = _make_intervals(prices)
# 2 tasks of 3 intervals each, with 2-interval (30 min) gap
tasks = _make_tasks(("washer", 3), ("dryer", 3))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=2,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 2
washer_end = datetime.fromisoformat(assignments[0]["intervals"][-1]["startsAt"]) + timedelta(minutes=15)
dryer_start = datetime.fromisoformat(assignments[1]["intervals"][0]["startsAt"])
# Gap should be at least 30 minutes (2 intervals × 15 min)
gap = dryer_start - washer_end
assert gap >= timedelta(minutes=30)
def test_sequential_chain_breaks_on_failure(self) -> None:
"""If a sequential task can't be placed, all later tasks are unscheduled."""
# Only 6 intervals — not enough for 3 tasks of 3 intervals each
prices = [10.0] * 6
pool = _make_intervals(prices)
tasks = _make_tasks(("task_a", 3), ("task_b", 3), ("task_c", 3))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
# Task A and B fit (6 intervals total), task C doesn't
assert len(assignments) == 2
assert assignments[0]["name"] == "task_a"
assert assignments[1]["name"] == "task_b"
assert unscheduled == ["task_c"]
def test_sequential_all_fail_after_first_failure(self) -> None:
"""If the first task fails in sequential mode, all are unscheduled."""
# 2 intervals — not enough for any 3-interval task
prices = [10.0] * 2
pool = _make_intervals(prices)
tasks = _make_tasks(("task_a", 3), ("task_b", 2))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
# Task A can't fit (needs 3, only 2 available)
# Task B should also be unscheduled because the chain is broken
assert len(assignments) == 0
assert unscheduled == ["task_a", "task_b"]
def test_sequential_optimizes_within_window(self) -> None:
"""Sequential still finds cheapest window within each task's available range."""
# 12 intervals: pattern cheap-expensive-cheap-expensive...
# First 6 for task A, second 6 for task B
# Within each half, there's a cheaper sub-window
prices = [20.0, 5.0, 5.0, 20.0, 20.0, 20.0, 20.0, 20.0, 5.0, 5.0, 20.0, 20.0]
pool = _make_intervals(prices)
tasks = _make_tasks(("task_a", 2), ("task_b", 2))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 2
# Task A should pick the cheapest 2-interval window: indices 1-2 (price 5.0 each)
a_prices = [iv["total"] for iv in assignments[0]["intervals"]]
assert a_prices == [5.0, 5.0]
# Task B searches from index 2 onward, cheapest is indices 8-9 (price 5.0 each)
b_prices = [iv["total"] for iv in assignments[1]["intervals"]]
assert b_prices == [5.0, 5.0]
def test_sequential_single_task_same_as_non_sequential(self) -> None:
"""With a single task, sequential and non-sequential produce the same result."""
prices = [20.0, 5.0, 5.0, 20.0, 10.0, 10.0]
pool = _make_intervals(prices)
tasks = _make_tasks(("only_task", 2))
a_seq, u_seq, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
a_non, u_non, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=False,
)
assert not u_seq
assert not u_non
assert len(a_seq) == len(a_non) == 1
assert a_seq[0]["intervals"] == a_non[0]["intervals"]
class TestSequentialThreeTasks:
"""Sequential scheduling with three tasks (washer → dryer → fold reminder)."""
def test_three_tasks_chained(self) -> None:
"""Three sequential tasks are placed in order with no overlap."""
# 24 intervals (6 hours) with varying prices
prices = [15.0, 10.0, 5.0, 5.0, 10.0, 15.0, 20.0, 25.0] * 3
pool = _make_intervals(prices)
tasks = _make_tasks(("washer", 4), ("dryer", 3), ("fold", 1))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=0,
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 3
assert assignments[0]["name"] == "washer"
assert assignments[1]["name"] == "dryer"
assert assignments[2]["name"] == "fold"
# Verify no overlap: each task starts after previous ends
for i in range(1, len(assignments)):
prev_end = datetime.fromisoformat(assignments[i - 1]["intervals"][-1]["startsAt"]) + timedelta(minutes=15)
curr_start = datetime.fromisoformat(assignments[i]["intervals"][0]["startsAt"])
assert curr_start >= prev_end
def test_three_tasks_with_gap(self) -> None:
"""Three sequential tasks respect gap between each pair."""
prices = [10.0] * 24
pool = _make_intervals(prices)
tasks = _make_tasks(("washer", 4), ("dryer", 3), ("fold", 1))
assignments, unscheduled, _ = _attempt_schedule(
pool,
max_price_level=None,
min_price_level=None,
tasks=tasks,
gap_intervals=1, # 15 min gap
smooth_outliers=False,
sequential=True,
)
assert not unscheduled
assert len(assignments) == 3
# Verify gaps between each pair
for i in range(1, len(assignments)):
prev_end = datetime.fromisoformat(assignments[i - 1]["intervals"][-1]["startsAt"]) + timedelta(minutes=15)
curr_start = datetime.fromisoformat(assignments[i]["intervals"][0]["startsAt"])
gap = curr_start - prev_end
assert gap >= timedelta(minutes=15)