diff --git a/custom_components/tibber_prices/services/find_cheapest_block.py b/custom_components/tibber_prices/services/find_cheapest_block.py index 9995202..6015bce 100644 --- a/custom_components/tibber_prices/services/find_cheapest_block.py +++ b/custom_components/tibber_prices/services/find_cheapest_block.py @@ -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, diff --git a/custom_components/tibber_prices/services/find_cheapest_hours.py b/custom_components/tibber_prices/services/find_cheapest_hours.py index 7c888ad..cca4047 100644 --- a/custom_components/tibber_prices/services/find_cheapest_hours.py +++ b/custom_components/tibber_prices/services/find_cheapest_hours.py @@ -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, diff --git a/custom_components/tibber_prices/services/find_cheapest_schedule.py b/custom_components/tibber_prices/services/find_cheapest_schedule.py index 6e90c54..881bea1 100644 --- a/custom_components/tibber_prices/services/find_cheapest_schedule.py +++ b/custom_components/tibber_prices/services/find_cheapest_schedule.py @@ -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): diff --git a/tests/services/test_find_service_responses.py b/tests/services/test_find_service_responses.py index d3ad31e..b4ae4b7 100644 --- a/tests/services/test_find_service_responses.py +++ b/tests/services/test_find_service_responses.py @@ -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.