diff --git a/custom_components/tibber_prices/services/helpers.py b/custom_components/tibber_prices/services/helpers.py index 44f0c9c..d6036e7 100644 --- a/custom_components/tibber_prices/services/helpers.py +++ b/custom_components/tibber_prices/services/helpers.py @@ -717,6 +717,15 @@ def check_min_distance_from_avg( For cheapest searches: window mean must be at least X% BELOW range average. For most expensive searches: window mean must be at least X% ABOVE range average. + CRITICAL: Uses abs(range_avg) to scale the distance so the direction of the + threshold shift is always correct, even when range_avg is negative (Tibber + prices can go negative during grid oversupply). Multiplying a negative + range_avg directly by (1 ± ratio) 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). This mirrors the + abs()-based normalization already used for the period system's + min_distance_from_avg handling (see coordinator/period_handlers/level_filtering.py). + Args: window_mean_base: Window mean price in BASE currency (not display unit). range_avg: Search range average price in BASE currency. @@ -731,10 +740,11 @@ def check_min_distance_from_avg( return True # Cannot calculate percentage difference from zero distance_ratio = min_distance_pct / 100 + distance_amount = abs(range_avg) * distance_ratio if reverse: - # Most expensive: window mean must be >= avg * (1 + distance) - threshold = range_avg * (1 + distance_ratio) + # Most expensive: window mean must be >= avg + distance + threshold = range_avg + distance_amount return window_mean_base >= threshold - # Cheapest: window mean must be <= avg * (1 - distance) - threshold = range_avg * (1 - distance_ratio) + # Cheapest: window mean must be <= avg - distance + threshold = range_avg - distance_amount return window_mean_base <= threshold diff --git a/tests/services/test_service_helpers.py b/tests/services/test_service_helpers.py new file mode 100644 index 0000000..50eb152 --- /dev/null +++ b/tests/services/test_service_helpers.py @@ -0,0 +1,71 @@ +"""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