mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
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:
parent
2ad225f26d
commit
95004efa2d
4 changed files with 77 additions and 3 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue