mirror of
https://github.com/jpawlowski/hass.tibber_prices.git
synced 2026-03-29 21:03:40 +00:00
Best Price min_distance now uses negative values (-50 to 0) to match semantic meaning "below average". Peak Price continues using positive values (0 to 50) for "above average". Uniform formula: avg * (1 + distance/100) works for both period types. Sign indicates direction: negative = toward MIN (cheap), positive = toward MAX (expensive). Changes: - const.py: DEFAULT_BEST_PRICE_MIN_DISTANCE_FROM_AVG = -5 (was 5) - schemas.py: Best Price range -50 to 0, Peak Price range 0 to 50 - validators.py: Separate validate_best_price_distance_percentage() - level_filtering.py: Simplified to uniform formula (removed conditionals) - translations: Separate error messages for Best/Peak distance validation - tests: 37 comprehensive validator tests (100% coverage) Impact: Configuration UI now visually represents direction relative to average. Users see intuitive negative values for "below average" pricing.
139 lines
5.7 KiB
Python
139 lines
5.7 KiB
Python
"""Test midnight turnover consistency - period visibility before/after midnight."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timedelta
|
|
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_price_interval(dt: datetime, price: float) -> dict:
|
|
"""Create a price interval dict."""
|
|
return {
|
|
"startsAt": dt,
|
|
"total": price,
|
|
"level": "NORMAL",
|
|
"rating_level": "NORMAL",
|
|
}
|
|
|
|
|
|
def create_price_data_scenario() -> tuple[list[dict], list[dict], list[dict], list[dict]]:
|
|
"""Create a realistic price scenario with a period crossing midnight."""
|
|
tz = ZoneInfo("Europe/Berlin")
|
|
base = datetime(2025, 11, 21, 0, 0, 0, tzinfo=tz)
|
|
|
|
# Define cheap hour ranges for each day
|
|
cheap_hours = {
|
|
"yesterday": range(22, 24), # 22:00-23:45
|
|
"today": range(21, 24), # 21:00-23:45 (crosses midnight!)
|
|
"tomorrow": range(1), # 00:00-00:45 (continuation)
|
|
}
|
|
|
|
def generate_day_prices(day_dt: datetime, cheap_range: range) -> list[dict]:
|
|
"""Generate 15-min interval prices for a day."""
|
|
prices = []
|
|
for hour in range(24):
|
|
for minute in [0, 15, 30, 45]:
|
|
dt = day_dt.replace(hour=hour, minute=minute)
|
|
price = 15.0 if hour in cheap_range else 30.0
|
|
prices.append(create_price_interval(dt, price))
|
|
return prices
|
|
|
|
yesterday_prices = generate_day_prices(base - timedelta(days=1), cheap_hours["yesterday"])
|
|
today_prices = generate_day_prices(base, cheap_hours["today"])
|
|
tomorrow_prices = generate_day_prices(base + timedelta(days=1), cheap_hours["tomorrow"])
|
|
day_after_tomorrow_prices = generate_day_prices(base + timedelta(days=2), range(0)) # No cheap hours
|
|
|
|
return yesterday_prices, today_prices, tomorrow_prices, day_after_tomorrow_prices
|
|
|
|
|
|
@pytest.fixture
|
|
def period_config() -> TibberPricesPeriodConfig:
|
|
"""Provide test period configuration."""
|
|
return TibberPricesPeriodConfig(
|
|
reverse_sort=False, # Best price (cheap periods)
|
|
flex=0.50, # 50% flexibility
|
|
min_distance_from_avg=-5.0, # -5% below average
|
|
min_period_length=60, # 60 minutes minimum
|
|
threshold_low=20.0,
|
|
threshold_high=30.0,
|
|
threshold_volatility_moderate=0.3,
|
|
threshold_volatility_high=0.5,
|
|
threshold_volatility_very_high=0.7,
|
|
level_filter=None,
|
|
gap_count=0,
|
|
)
|
|
|
|
|
|
@pytest.mark.integration
|
|
def test_midnight_crossing_period_consistency(period_config: TibberPricesPeriodConfig) -> None:
|
|
"""
|
|
Test that midnight-crossing periods remain visible before and after midnight turnover.
|
|
|
|
This test simulates the real-world scenario where:
|
|
- Before midnight (21st 22:00): Period 21:00→01:00 is visible
|
|
- After midnight (22nd 00:30): Same period should still be visible
|
|
|
|
The period starts on 2025-11-21 (yesterday after turnover) and ends on 2025-11-22 (today).
|
|
"""
|
|
tz = ZoneInfo("Europe/Berlin")
|
|
yesterday_prices, today_prices, tomorrow_prices, day_after_tomorrow_prices = create_price_data_scenario()
|
|
|
|
# SCENARIO 1: Before midnight (today = 2025-11-21 22:00)
|
|
current_time_before = datetime(2025, 11, 21, 22, 0, 0, tzinfo=tz)
|
|
time_service_before = TibberPricesTimeService(current_time_before)
|
|
all_prices_before = yesterday_prices + today_prices + tomorrow_prices
|
|
|
|
result_before = calculate_periods(all_prices_before, config=period_config, time=time_service_before)
|
|
periods_before = result_before["periods"]
|
|
|
|
# Find the midnight-crossing period (starts 21st, ends 22nd)
|
|
midnight_period_before = None
|
|
for period in periods_before:
|
|
if period["start"].date().isoformat() == "2025-11-21" and period["end"].date().isoformat() == "2025-11-22":
|
|
midnight_period_before = period
|
|
break
|
|
|
|
assert midnight_period_before is not None, "Expected to find midnight-crossing period before turnover"
|
|
|
|
# SCENARIO 2: After midnight turnover (today = 2025-11-22 00:30)
|
|
current_time_after = datetime(2025, 11, 22, 0, 30, 0, tzinfo=tz)
|
|
time_service_after = TibberPricesTimeService(current_time_after)
|
|
|
|
# Simulate coordinator data shift: yesterday=21st, today=22nd, tomorrow=23rd
|
|
yesterday_after_turnover = today_prices
|
|
today_after_turnover = tomorrow_prices
|
|
tomorrow_after_turnover = day_after_tomorrow_prices
|
|
all_prices_after = yesterday_after_turnover + today_after_turnover + tomorrow_after_turnover
|
|
|
|
result_after = calculate_periods(all_prices_after, config=period_config, time=time_service_after)
|
|
periods_after = result_after["periods"]
|
|
|
|
# Find period that started on 2025-11-21 (now "yesterday")
|
|
period_from_yesterday_after = None
|
|
for period in periods_after:
|
|
if period["start"].date().isoformat() == "2025-11-21":
|
|
period_from_yesterday_after = period
|
|
break
|
|
|
|
assert period_from_yesterday_after is not None, (
|
|
"Expected midnight-crossing period to remain visible after turnover (we're at 00:30, period ends at 01:00)"
|
|
)
|
|
|
|
# Verify consistency: same absolute times
|
|
assert midnight_period_before["start"] == period_from_yesterday_after["start"], "Start time should match"
|
|
assert midnight_period_before["end"] == period_from_yesterday_after["end"], "End time should match"
|
|
assert midnight_period_before["duration_minutes"] == period_from_yesterday_after["duration_minutes"], (
|
|
"Duration should match"
|
|
)
|