mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-07-27 17:26:48 +00:00
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:
parent
95004efa2d
commit
1b207f0db1
2 changed files with 85 additions and 4 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
71
tests/services/test_service_helpers.py
Normal file
71
tests/services/test_service_helpers.py
Normal 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
|
||||||
Loading…
Reference in a new issue