mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-05-28 18:43:40 +00:00
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.
171 lines
6.6 KiB
Python
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
|