hass.tibber_prices/tests/services/test_service_helpers.py
Julian Pawlowski 1b207f0db1 fix(services): correct min_distance_from_avg direction for negative prices
check_min_distance_from_avg() computed the distance threshold as
range_avg * (1 ± ratio). Tibber prices can go negative during grid
oversupply, and multiplying a negative range_avg directly flips the
intended direction: e.g. avg * 1.05 makes a negative average MORE
negative (i.e. cheaper), which is the wrong direction for a
"most expensive" threshold check, and analogously wrong for
"cheapest" checks.

Compute the threshold as range_avg ± abs(range_avg) * ratio instead,
matching the sign-safe normalization pattern already used for
min_distance_from_avg in the period system
(coordinator/period_handlers/level_filtering.py). Behavior for
positive averages (the common case) is unchanged.

Used by find_cheapest_block, find_cheapest_hours, and plan_charging.

Impact: min_distance_from_avg now correctly filters cheapest/most
expensive windows during negative-price periods instead of silently
accepting windows in the wrong direction relative to the search range
average.
2026-07-04 18:23:06 +00:00

71 lines
3.4 KiB
Python

"""Tests for small pure helper functions in services/helpers.py."""
from __future__ import annotations
from custom_components.tibber_prices.services.helpers import check_min_distance_from_avg
class TestCheckMinDistanceFromAvgPositiveAverage:
"""Regression coverage for the common case: positive range average."""
def test_cheapest_passes_when_far_enough_below(self) -> None:
"""Window 10% below a positive average passes a 5% requirement."""
assert check_min_distance_from_avg(18.0, 20.0, 5.0, reverse=False) is True
def test_cheapest_fails_when_too_close(self) -> None:
"""Window only 2% below a positive average fails a 5% requirement."""
assert check_min_distance_from_avg(19.6, 20.0, 5.0, reverse=False) is False
def test_most_expensive_passes_when_far_enough_above(self) -> None:
"""Window 10% above a positive average passes a 5% requirement."""
assert check_min_distance_from_avg(22.0, 20.0, 5.0, reverse=True) is True
def test_most_expensive_fails_when_too_close(self) -> None:
"""Window only 2% above a positive average fails a 5% requirement."""
assert check_min_distance_from_avg(20.4, 20.0, 5.0, reverse=True) is False
class TestCheckMinDistanceFromAvgNegativeAverage:
"""Regression coverage for GH-reported sign bug: negative range average.
Tibber prices can go negative during grid oversupply. The threshold must
be computed as `avg ± abs(avg) * ratio` (not `avg * (1 ± ratio)`), since
multiplying a negative average directly flips the intended direction.
"""
def test_cheapest_passes_when_more_negative_than_avg(self) -> None:
"""A window that is 10% cheaper (more negative) than a -5 avg passes 5% req."""
# -5 - abs(-5)*0.05 = -5.25 → window must be <= -5.25
assert check_min_distance_from_avg(-5.6, -5.0, 5.0, reverse=False) is True
def test_cheapest_fails_when_less_negative_than_avg(self) -> None:
"""A window that is actually more expensive (less negative) than avg must fail.
Regression: the old buggy formula (avg * (1 - ratio)) computed a
threshold of -4.75 here, incorrectly letting -5.1 pass even though
it is only ~2% below avg (needs 5%).
"""
assert check_min_distance_from_avg(-5.1, -5.0, 5.0, reverse=False) is False
def test_most_expensive_passes_when_less_negative_than_avg(self) -> None:
"""A window 10% above (less negative than) a -5 avg passes a 5% req."""
# -5 + abs(-5)*0.05 = -4.75 → window must be >= -4.75
assert check_min_distance_from_avg(-4.4, -5.0, 5.0, reverse=True) is True
def test_most_expensive_fails_when_actually_cheaper_than_avg(self) -> None:
"""A window that is cheaper (more negative) than avg must fail the most-expensive check.
Regression: the old buggy formula (avg * (1 + ratio)) computed a
threshold of -5.25 here, incorrectly letting -5.1 pass even though
it is actually cheaper than the average, not more expensive.
"""
assert check_min_distance_from_avg(-5.1, -5.0, 5.0, reverse=True) is False
class TestCheckMinDistanceFromAvgEdgeCases:
"""Edge cases: zero average."""
def test_zero_average_always_passes(self) -> None:
"""A zero average makes percentage distance undefined; always pass."""
assert check_min_distance_from_avg(5.0, 0.0, 5.0, reverse=False) is True
assert check_min_distance_from_avg(-5.0, 0.0, 5.0, reverse=True) is True