hass.tibber_prices/tests/test_best_price_e2e.py
Julian Pawlowski 1e1c8d5299 feat(periods): handle flat days and absolute low-price scenarios
Three complementary fixes for pathological price days:

1. Adaptive min_periods for flat days (CV ≤ 10%):
   On days with nearly uniform prices (e.g. solar surplus), enforcing
   multiple distinct cheap periods is geometrically impossible.
   _compute_day_effective_min() detects CV ≤ LOW_CV_FLAT_DAY_THRESHOLD
   and reduces the effective target to 1 for that day (best price only;
   peak price always runs full relaxation).

2. min_distance scaling on absolute low-price days:
   When the daily average drops below 0.10 EUR (10 ct), percentage-based
   min_distance becomes unreliable. The threshold is scaled linearly to
   zero so the filter neither accepts the entire day nor blocks everything.

3. CV quality gate bypass for absolute low-price periods:
   Periods with a mean below 0.10 EUR may show high relative CV even
   though the absolute price differences are fractions of a cent.
   Both _check_period_quality() and _check_merge_quality_gate() now
   bypass the CV gate below this threshold.

Additionally: span-aware flex warnings now emit INFO/WARNING when
base_flex >= 25%/30% and at least one "normal" (non-V-shape) day
exists (FLEX_WARNING_VSHAPE_RATIO = 0.5). Previously the constants
were defined but never used.

Updated 3 test assertions in test_best_price_e2e.py: the flat-day
fixture (CV ~5.4%) correctly produces 1 period, not 2.

Impact: Best Price periods now appear reliably on V-shape solar days
and flat-price days. No more "0 periods" on days where the single
cheapest window is a valid and useful result.
2026-04-06 12:18:40 +00:00

394 lines
14 KiB
Python

"""
End-to-End Tests for Best Price Period Generation (Nov 2025 Bug Fix).
These tests validate that the sign convention bug fix works correctly:
- Bug: Negative flex for peak wasn't normalized → affected period calculation
- Fix: abs() normalization in periods.py ensures consistent behavior
Test coverage matches manual testing checklist:
1. ✅ Best periods generate (not 0)
2. ✅ FLEX filter stats reasonable (~20-40%, not 100%)
3. ✅ Relaxation succeeds at reasonable flex (not maxed at 50%)
"""
from __future__ import annotations
from unittest.mock import Mock
import pytest
from custom_components.tibber_prices.coordinator.period_handlers import (
TibberPricesPeriodConfig,
calculate_periods_with_relaxation,
)
from custom_components.tibber_prices.coordinator.time_service import (
TibberPricesTimeService,
)
from homeassistant.util import dt as dt_util
def _create_realistic_intervals() -> list[dict]:
"""
Create realistic test data matching German market Nov 22, 2025.
Pattern: Morning peak (6-9h), midday low (9-15h), evening moderate (15-24h).
Daily stats: Min=30.44ct, Avg=33.26ct, Max=36.03ct
"""
# Use CURRENT date so tests work regardless of when they run
now_local = dt_util.now()
base_time = now_local.replace(hour=0, minute=0, second=0, microsecond=0)
daily_min, daily_avg, daily_max = 0.3044, 0.3326, 0.3603
def _create_interval(hour: int, minute: int, price: float, level: str, rating: str) -> dict:
"""Create a single interval dict."""
return {
"startsAt": base_time.replace(hour=hour, minute=minute), # datetime object
"total": price,
"level": level,
"rating_level": rating,
"_original_price": price,
"trailing_avg_24h": daily_avg,
"daily_min": daily_min,
"daily_avg": daily_avg,
"daily_max": daily_max,
}
# Build all intervals as list comprehensions
intervals = []
# Overnight (00:00-06:00) - NORMAL
intervals.extend(
[_create_interval(hour, minute, 0.318, "NORMAL", "NORMAL") for hour in range(6) for minute in [0, 15, 30, 45]]
)
# Morning spike (06:00-09:00) - EXPENSIVE
intervals.extend(
[
_create_interval(
hour,
minute,
price := 0.33 + (hour - 6) * 0.01,
"EXPENSIVE" if price > 0.34 else "NORMAL",
"HIGH" if price > 0.35 else "NORMAL",
)
for hour in range(6, 9)
for minute in [0, 15, 30, 45]
]
)
# Midday low (09:00-15:00) - CHEAP
intervals.extend(
[
_create_interval(hour, minute, 0.305 + (hour - 12) * 0.002, "CHEAP", "LOW")
for hour in range(9, 15)
for minute in [0, 15, 30, 45]
]
)
# Evening moderate (15:00-24:00) - NORMAL to EXPENSIVE
intervals.extend(
[
_create_interval(
hour,
minute,
price := 0.32 + (hour - 15) * 0.005,
"EXPENSIVE" if price > 0.34 else "NORMAL",
"HIGH" if price > 0.35 else "NORMAL",
)
for hour in range(15, 24)
for minute in [0, 15, 30, 45]
]
)
return intervals
@pytest.mark.unit
@pytest.mark.freeze_time("2025-11-22 12:00:00+01:00")
class TestBestPriceGenerationWorks:
"""Validate that best price periods generate successfully after bug fix."""
def test_best_periods_generate_successfully(self) -> None:
"""
✅ PRIMARY TEST: Best periods generate (not 0).
Validates that positive flex for BEST price mode produces periods.
"""
intervals = _create_realistic_intervals()
# Mock coordinator (minimal setup)
mock_coordinator = Mock()
mock_coordinator.config_entry = Mock()
time_service = TibberPricesTimeService(mock_coordinator)
# Mock now() to return test date
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
time_service.now = Mock(return_value=test_time)
# Create config for BEST price mode (normal positive flex)
config = TibberPricesPeriodConfig(
flex=0.15, # 15% positive (BEST price mode)
min_distance_from_avg=5.0,
min_period_length=60, # Best price uses 60min default
reverse_sort=False, # Best price mode (cheapest first)
)
# Calculate periods with relaxation
result = calculate_periods_with_relaxation(
intervals,
config=config,
enable_relaxation=True,
min_periods=2,
max_relaxation_attempts=11,
should_show_callback=lambda _: True, # Allow all levels
time=time_service,
config_entry=mock_coordinator.config_entry,
)
periods = result.get("periods", [])
# Validation: periods found
# Note: test fixture is a flat day (CV≈5.4%). Adaptive min_periods correctly
# returns 1 period instead of forcing a 2nd artificial period.
assert len(periods) >= 1, f"Best periods should generate, got {len(periods)}"
def test_positive_flex_produces_periods(self) -> None:
"""
✅ TEST: Positive flex produces periods in BEST mode.
Validates standard positive flex behavior for cheapest periods.
"""
intervals = _create_realistic_intervals()
mock_coordinator = Mock()
mock_coordinator.config_entry = Mock()
time_service = TibberPricesTimeService(mock_coordinator)
# Mock now() to return test date
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
time_service.now = Mock(return_value=test_time)
# Test with positive flex (standard BEST mode)
config_positive = TibberPricesPeriodConfig(
flex=0.15, # Positive for BEST mode
min_distance_from_avg=5.0,
min_period_length=60,
reverse_sort=False,
)
result_pos = calculate_periods_with_relaxation(
intervals,
config=config_positive,
enable_relaxation=True,
min_periods=2,
max_relaxation_attempts=11,
should_show_callback=lambda _: True,
time=time_service,
config_entry=mock_coordinator.config_entry,
)
periods_pos = result_pos.get("periods", [])
# With positive flex, should find periods
# Note: flat day (CV≈5.4%) → adaptive min_periods returns 1 period (correct behavior).
assert len(periods_pos) >= 1, f"Should find periods with positive flex, got {len(periods_pos)}"
def test_periods_contain_low_prices(self) -> None:
"""
✅ TEST: Best periods contain low prices (not expensive ones).
Validates periods include cheap intervals, not expensive ones.
"""
intervals = _create_realistic_intervals()
mock_coordinator = Mock()
mock_coordinator.config_entry = Mock()
time_service = TibberPricesTimeService(mock_coordinator)
# Mock now() to return test date
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
time_service.now = Mock(return_value=test_time)
config = TibberPricesPeriodConfig(
flex=0.15,
min_distance_from_avg=5.0,
min_period_length=60,
reverse_sort=False,
)
result = calculate_periods_with_relaxation(
intervals,
config=config,
enable_relaxation=True,
min_periods=2,
max_relaxation_attempts=11,
should_show_callback=lambda _: True,
time=time_service,
config_entry=mock_coordinator.config_entry,
)
periods = result.get("periods", [])
daily_max = intervals[0]["daily_max"]
# Check period averages are NOT near daily maximum
# Note: period prices are in cents, daily stats are in euros
for period in periods:
period_avg = period.get("price_mean", 0)
assert period_avg < daily_max * 100 * 0.95, (
f"Best period has too high avg: {period_avg:.4f} ct vs daily_max={daily_max * 100:.4f} ct"
)
def test_relaxation_works_at_reasonable_flex(self) -> None:
"""
✅ TEST: Relaxation succeeds without maxing flex at 50%.
Validates relaxation finds periods at reasonable flex levels.
"""
intervals = _create_realistic_intervals()
mock_coordinator = Mock()
mock_coordinator.config_entry = Mock()
time_service = TibberPricesTimeService(mock_coordinator)
# Mock now() to return test date
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
time_service.now = Mock(return_value=test_time)
# Lower flex to trigger relaxation
config = TibberPricesPeriodConfig(
flex=0.10, # 10% - likely needs relaxation
min_distance_from_avg=5.0,
min_period_length=60,
reverse_sort=False,
)
result = calculate_periods_with_relaxation(
intervals,
config=config,
enable_relaxation=True,
min_periods=2,
max_relaxation_attempts=11,
should_show_callback=lambda _: True,
time=time_service,
config_entry=mock_coordinator.config_entry,
)
periods = result.get("periods", [])
# Should find at least 1 period.
# Note: flat day (CV≈5.4%) → adaptive min_periods correctly needs only 1 period,
# which baseline already provides without relaxation. This validates that
# over-relaxation is skipped on truly flat days.
assert len(periods) >= 1, "Should find at least 1 period"
# Check if relaxation was used
relaxation_meta = result.get("metadata", {}).get("relaxation", {})
if "max_flex_used" in relaxation_meta:
max_flex_used = relaxation_meta["max_flex_used"]
# Fix ensures reasonable flex is sufficient
assert max_flex_used <= 0.35, f"Flex should stay reasonable, got {max_flex_used * 100:.1f}%"
@pytest.mark.unit
@pytest.mark.freeze_time("2025-11-22 12:00:00+01:00")
class TestBestPriceBugRegressionValidation:
"""Regression tests ensuring consistent behavior with peak price fix."""
def test_metadata_shows_reasonable_flex_used(self) -> None:
"""
✅ REGRESSION: Metadata shows flex used was reasonable (not 50%).
This validates FLEX filter works correctly in BEST mode too.
"""
intervals = _create_realistic_intervals()
mock_coordinator = Mock()
mock_coordinator.config_entry = Mock()
time_service = TibberPricesTimeService(mock_coordinator)
# Mock now() to return test date
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
time_service.now = Mock(return_value=test_time)
config = TibberPricesPeriodConfig(
flex=0.15,
min_distance_from_avg=5.0,
min_period_length=60,
reverse_sort=False,
)
result = calculate_periods_with_relaxation(
intervals,
config=config,
enable_relaxation=True,
min_periods=2,
max_relaxation_attempts=11,
should_show_callback=lambda _: True,
time=time_service,
config_entry=mock_coordinator.config_entry,
)
# Check result metadata
# Check that relaxation didn't max out at 50%
metadata = result.get("metadata", {})
config_used = metadata.get("config", {})
if "flex" in config_used:
flex_used = config_used["flex"]
# Reasonable flex should be sufficient (not maxing out at 50%)
assert 0.10 <= flex_used <= 0.48, f"Expected flex 10-48%, got {flex_used * 100:.1f}%"
# Also check relaxation metadata
relaxation_meta = result.get("metadata", {}).get("relaxation", {})
if "max_flex_used" in relaxation_meta:
max_flex = relaxation_meta["max_flex_used"]
# Should not max out at 50%
assert max_flex <= 0.48, f"Max flex should be reasonable, got {max_flex * 100:.1f}%"
def test_periods_include_cheap_intervals(self) -> None:
"""
✅ REGRESSION: Best periods include intervals near daily min.
Validates that cheap intervals are properly included in periods.
"""
intervals = _create_realistic_intervals()
mock_coordinator = Mock()
mock_coordinator.config_entry = Mock()
time_service = TibberPricesTimeService(mock_coordinator)
# Mock now() to return test date
test_time = dt_util.parse_datetime("2025-11-22T12:00:00+01:00")
time_service.now = Mock(return_value=test_time)
config = TibberPricesPeriodConfig(
flex=0.15,
min_distance_from_avg=5.0,
min_period_length=60,
reverse_sort=False,
)
result = calculate_periods_with_relaxation(
intervals,
config=config,
enable_relaxation=True,
min_periods=2,
max_relaxation_attempts=11,
should_show_callback=lambda _: True,
time=time_service,
config_entry=mock_coordinator.config_entry,
)
periods = result.get("periods", [])
daily_avg = intervals[0]["daily_avg"]
daily_min = intervals[0]["daily_min"]
# At least one period should have low average
# Note: period prices are in cents, daily stats are in euros
min_period_avg = min(p.get("price_mean", 1.0) for p in periods)
assert min_period_avg <= daily_avg * 100 * 0.95, (
f"Best periods should have low avg: {min_period_avg:.4f} ct vs daily_avg={daily_avg * 100:.4f} ct"
)
# Check proximity to daily min
assert min_period_avg <= daily_min * 100 * 1.15, (
f"At least one period near daily_min: {min_period_avg:.4f} ct vs daily_min={daily_min * 100:.4f} ct"
)