fix(services): prevent power_profile truncation during relaxation

find_cheapest_block, find_cheapest_hours, and find_cheapest_schedule
accept an optional power_profile (fixed per-interval watt array
matching the requested duration/task length). When allow_relaxation
was enabled and no full-duration window could be found, the
duration-reduction relaxation phase silently reduced the interval
count without shrinking power_profile accordingly. Downstream code
(find_cheapest_contiguous_window, calculate_window_statistics,
_find_cheapest_window_in_pool) then truncated the profile from the
front to match, dropping trailing appliance-cycle phases and using
the wrong per-interval weights for both window selection and the
reported cost estimate.

Disable duration-reduction relaxation whenever a power_profile is
supplied (find_cheapest_schedule guards per-task via
any_task_has_power_profile); distance and level-filter relaxation
phases remain available since they don't affect interval count.

Impact: Services with a power_profile no longer silently pick a
shorter window with mismatched power weighting during relaxation;
they now correctly report no window found if the full duration isn't
available, preserving the accuracy of appliance-cycle cost estimates.
This commit is contained in:
Julian Pawlowski 2026-07-04 18:22:44 +00:00
parent 2ad225f26d
commit 95004efa2d
4 changed files with 77 additions and 3 deletions

View file

@ -368,7 +368,17 @@ async def _handle_find_block(
# --- Relaxation loop ---
if result is None and allow_relaxation:
max_reduction = calculate_max_duration_reduction_intervals(duration_intervals, duration_flexibility_minutes)
# A power_profile is a fixed per-interval watt array matching the original
# requested duration. Reducing duration during relaxation would silently
# truncate it (dropping trailing phases of the appliance cycle) and
# invalidate the weighted window selection, so duration reduction is
# disabled whenever a power_profile is supplied. Distance and level-filter
# relaxation remain available since they don't affect interval count.
max_reduction = (
0
if power_profile
else calculate_max_duration_reduction_intervals(duration_intervals, duration_flexibility_minutes)
)
steps = generate_relaxation_steps(
min_distance_from_avg=min_distance_from_avg,
max_price_level=max_price_level,

View file

@ -443,7 +443,18 @@ async def _handle_find_hours(
# --- Relaxation loop ---
if result is None and allow_relaxation:
max_reduction = calculate_max_duration_reduction_intervals(total_intervals, duration_flexibility_minutes)
# A power_profile is a fixed per-interval watt array matching the original
# requested duration. Reducing the interval count during relaxation would
# silently truncate it (dropping trailing phases of the appliance cycle)
# when building the weighted cost estimate, so duration reduction is
# disabled whenever a power_profile is supplied. Distance and
# level-filter relaxation remain available since they don't change the
# interval count.
max_reduction = (
0
if power_profile
else calculate_max_duration_reduction_intervals(total_intervals, duration_flexibility_minutes)
)
min_dur = max(MIN_RELAXED_DURATION_INTERVALS, min_segment_intervals)
steps = generate_relaxation_steps(
min_distance_from_avg=min_distance_from_avg,

View file

@ -571,7 +571,13 @@ async def handle_find_cheapest_schedule(call: ServiceCall) -> ServiceResponse:
break
# Phase 2: Duration reduction (uniform across all tasks)
if best_unscheduled:
# Skipped when any task has a power_profile: it's a fixed per-interval
# watt array matching that task's original duration, and reducing
# duration_intervals here without shrinking the profile would silently
# truncate it (dropping trailing phases of the appliance cycle) when
# used for weighted window selection and cost estimation.
any_task_has_power_profile = any(t.get("power_profile") for t in tasks)
if best_unscheduled and not any_task_has_power_profile:
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):

View file

@ -352,6 +352,53 @@ async def test_block_handler_preserves_service_search_data(monkeypatch: pytest.M
assert response["must_finish_by"] == deadline.isoformat()
@pytest.mark.asyncio
async def test_block_handler_power_profile_blocks_duration_relaxation(monkeypatch: pytest.MonkeyPatch) -> None:
"""Regression: a power_profile must disable relaxation's duration-reduction phase.
power_profile is a fixed per-interval watt array matching the *original*
requested duration. If relaxation reduced the duration to fit a shorter
pool, the profile would be silently truncated from the front (dropping
trailing appliance-cycle phases) and used to weight/report a window that
doesn't match the user's declared profile. Only 3 intervals are available
here for a 4-interval (1h) request without the fix, relaxation's
duration phase would find and accept a 3-interval window using a
truncated profile; with the fix, duration reduction is skipped and
relaxation exhausts instead.
"""
intervals = _make_intervals([10.0, 10.0, 10.0]) # only 3 available, need 4
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, 1, 0, tzinfo=UTC),
),
)
call = SimpleNamespace(
hass=object(),
data={
"duration": timedelta(hours=1), # 4 intervals required
"power_profile": [100, 200, 300, 400],
"use_base_unit": True,
"allow_relaxation": True,
"smooth_outliers": False,
},
)
response = cast("dict[str, Any]", await handle_find_cheapest_block(cast("ServiceCall", call)))
assert response["window_found"] is False
assert response["reason"] == "relaxation_exhausted"
# Duration must never be silently reduced below the original request.
assert response["duration_minutes"] == 60
class _FakeRangeFilteringPool:
"""Fake interval pool that honors start_time/end_time like the real pool.