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.
This commit is contained in:
Julian Pawlowski 2026-07-04 18:23:06 +00:00
parent 95004efa2d
commit 1b207f0db1
2 changed files with 85 additions and 4 deletions

View file

@ -717,6 +717,15 @@ def check_min_distance_from_avg(
For cheapest searches: window mean must be at least X% BELOW range average. 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. 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: Args:
window_mean_base: Window mean price in BASE currency (not display unit). window_mean_base: Window mean price in BASE currency (not display unit).
range_avg: Search range average price in BASE currency. 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 return True # Cannot calculate percentage difference from zero
distance_ratio = min_distance_pct / 100 distance_ratio = min_distance_pct / 100
distance_amount = abs(range_avg) * distance_ratio
if reverse: if reverse:
# Most expensive: window mean must be >= avg * (1 + distance) # Most expensive: window mean must be >= avg + distance
threshold = range_avg * (1 + distance_ratio) threshold = range_avg + distance_amount
return window_mean_base >= threshold return window_mean_base >= threshold
# Cheapest: window mean must be <= avg * (1 - distance) # Cheapest: window mean must be <= avg - distance
threshold = range_avg * (1 - distance_ratio) threshold = range_avg - distance_amount
return window_mean_base <= threshold return window_mean_base <= threshold

View file

@ -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