hass.tibber_prices/tests/test_negative_best_price_periods.py
Julian Pawlowski 10c83d6720 fix(periods): keep negative best-price windows strictly local
Always treat prices at or below zero as valid best-price intervals, rescue short
negative cores with directly adjacent cheap shoulders before min-length filtering,
and block geometric or shape-based widening for periods that already contain a
negative-price core.

Impact: Negative best-price periods no longer expand into positive edge intervals on days with extreme negative prices.
2026-04-25 20:00:04 +00:00

171 lines
6.6 KiB
Python

"""Regression tests for best-price periods with negative prices."""
from __future__ import annotations
from datetime import datetime
from zoneinfo import ZoneInfo
import pytest
from custom_components.tibber_prices.coordinator.period_handlers.core import calculate_periods
from custom_components.tibber_prices.coordinator.period_handlers.types import TibberPricesPeriodConfig
from custom_components.tibber_prices.coordinator.time_service import TibberPricesTimeService
def _create_interval(dt: datetime, price: float, level: str) -> dict:
"""Create a single interval dict."""
rating = "LOW" if price <= 5.0 else "NORMAL"
return {
"startsAt": dt,
"total": price,
"level": level,
"rating_level": rating,
"_original_price": price,
}
def _build_day_with_overrides(overrides: dict[tuple[int, int], tuple[float, str]]) -> list[dict]:
"""Build a day of 15-minute intervals with targeted overrides."""
tz = ZoneInfo("Europe/Berlin")
base = datetime(2025, 4, 25, 0, 0, 0, tzinfo=tz)
intervals: list[dict] = []
for hour in range(24):
for minute in (0, 15, 30, 45):
price, level = overrides.get((hour, minute), (20.0, "NORMAL"))
intervals.append(_create_interval(base.replace(hour=hour, minute=minute), price, level))
return intervals
def _create_time_service() -> TibberPricesTimeService:
"""Create a deterministic time service for period calculation."""
tz = ZoneInfo("Europe/Berlin")
return TibberPricesTimeService(datetime(2025, 4, 25, 12, 0, 0, tzinfo=tz))
def _create_day_pattern(valley_start: tuple[int, int], valley_end: tuple[int, int]) -> dict:
"""Create a minimal day-pattern dict for geometric flex tests."""
tz = ZoneInfo("Europe/Berlin")
base = datetime(2025, 4, 25, 0, 0, 0, tzinfo=tz)
return {
"pattern": "valley",
"confidence": 1.0,
"day_cv_percent": 100.0,
"segments": [],
"extreme_time": base.replace(hour=13, minute=30),
"valley_start": base.replace(hour=valley_start[0], minute=valley_start[1]),
"valley_end": base.replace(hour=valley_end[0], minute=valley_end[1]),
"peak_start": None,
"peak_end": None,
}
@pytest.mark.unit
class TestNegativeBestPricePeriods:
"""Validate local shoulder rescue around short negative cores only."""
def test_short_negative_dip_can_be_rescued_by_local_shoulders(self) -> None:
"""A short negative core may extend into directly adjacent cheap shoulders."""
intervals = _build_day_with_overrides(
{
(10, 45): (5.0, "CHEAP"),
(11, 0): (2.0, "CHEAP"),
(11, 15): (-1.5, "VERY_CHEAP"),
(11, 30): (-1.0, "VERY_CHEAP"),
(11, 45): (2.0, "CHEAP"),
(12, 0): (5.0, "CHEAP"),
}
)
config = TibberPricesPeriodConfig(
reverse_sort=False,
flex=0.15,
min_distance_from_avg=5.0,
min_period_length=60,
)
result = calculate_periods(intervals, config=config, time=_create_time_service())
periods = result["periods"]
assert len(periods) == 1, "Expected the short negative dip to survive as one local period"
assert periods[0]["start"].hour == 11 and periods[0]["start"].minute == 0
assert periods[0]["end"].hour == 12 and periods[0]["end"].minute == 0
assert periods[0]["duration_minutes"] == 60
def test_long_negative_block_stays_negative_only(self) -> None:
"""A multi-hour negative block must not pull in positive shoulders."""
intervals = _build_day_with_overrides(
{
(10, 30): (2.0, "CHEAP"),
(10, 45): (2.0, "CHEAP"),
(11, 0): (-1.5, "VERY_CHEAP"),
(11, 15): (-1.4, "VERY_CHEAP"),
(11, 30): (-1.3, "VERY_CHEAP"),
(11, 45): (-1.2, "VERY_CHEAP"),
(12, 0): (-1.1, "VERY_CHEAP"),
(12, 15): (-1.0, "VERY_CHEAP"),
(12, 30): (-0.9, "VERY_CHEAP"),
(12, 45): (-0.8, "VERY_CHEAP"),
(13, 0): (2.0, "CHEAP"),
(13, 15): (2.0, "CHEAP"),
}
)
config = TibberPricesPeriodConfig(
reverse_sort=False,
flex=0.15,
min_distance_from_avg=5.0,
min_period_length=180,
)
result = calculate_periods(intervals, config=config, time=_create_time_service())
assert result["periods"] == [], "Long negative blocks should not be widened with positive shoulders"
def test_negative_core_ignores_geometric_and_shape_extension(self) -> None:
"""Negative best-price periods must not widen via geometric or shape extension."""
intervals = _build_day_with_overrides(
{
(11, 45): (7.93, "VERY_CHEAP"),
(12, 0): (4.5, "VERY_CHEAP"),
(12, 15): (-1.0, "VERY_CHEAP"),
(12, 30): (-2.0, "VERY_CHEAP"),
(12, 45): (-3.0, "VERY_CHEAP"),
(13, 0): (-4.0, "VERY_CHEAP"),
(13, 15): (-5.36, "VERY_CHEAP"),
(13, 30): (-4.5, "VERY_CHEAP"),
(13, 45): (-3.5, "VERY_CHEAP"),
(14, 0): (-2.5, "VERY_CHEAP"),
(14, 15): (-1.5, "VERY_CHEAP"),
(14, 30): (-0.5, "VERY_CHEAP"),
(14, 45): (2.0, "VERY_CHEAP"),
(15, 0): (4.0, "VERY_CHEAP"),
(15, 15): (7.0, "VERY_CHEAP"),
}
)
config = TibberPricesPeriodConfig(
reverse_sort=False,
flex=0.15,
min_distance_from_avg=5.0,
min_period_length=60,
extend_to_extreme=True,
max_extension_intervals=4,
geometric_extra_flex=0.20,
)
day_patterns_by_date = {
datetime(2025, 4, 25, 0, 0, 0, tzinfo=ZoneInfo("Europe/Berlin")).date(): _create_day_pattern(
(11, 45), (15, 15)
)
}
result = calculate_periods(
intervals,
config=config,
time=_create_time_service(),
day_patterns_by_date=day_patterns_by_date,
)
assert len(result["periods"]) == 1
assert result["periods"][0]["start"].hour == 12 and result["periods"][0]["start"].minute == 15
assert result["periods"][0]["end"].hour == 14 and result["periods"][0]["end"].minute == 45
assert result["periods"][0].get("geometric_extension_active") is None
assert result["periods"][0].get("extension_intervals_added") is None