diff --git a/tests/test_period_calculation_gap_issue.py b/tests/test_period_calculation_gap_issue.py new file mode 100644 index 0000000..817d859 --- /dev/null +++ b/tests/test_period_calculation_gap_issue.py @@ -0,0 +1,220 @@ +""" +Test for period calculation issue where qualifying intervals after minimum price are skipped. + +Issue: When the daily minimum price occurs in the middle of the day, intervals AFTER +the minimum that qualify (low price) might not be included in best price periods +if there are non-qualifying intervals between them and previous periods. + +Example scenario (Dec 25, 2025 type): +- 00:00-06:00: Price 0.30 (CHEAP, qualifies for best price) +- 06:00-12:00: Price 0.35 (NORMAL, doesn't qualify) +- 12:00-13:00: Price 0.28 (CHEAP, MINIMUM - qualifies) +- 13:00-15:00: Price 0.36 (NORMAL, doesn't qualify) +- 15:00-18:00: Price 0.29 (CHEAP, qualifies - should be included!) +- 18:00-24:00: Price 0.38 (EXPENSIVE, doesn't qualify) + +Expected: Three separate periods (00:00-06:00, 12:00-13:00, 15:00-18:00) +Actual (bug): Only two periods (00:00-06:00, 12:00-13:00) - third period skipped + +Root cause: Sequential processing with temporal continuity requirement. +""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from custom_components.tibber_prices.coordinator.period_handlers import ( + TibberPricesPeriodConfig, + calculate_periods, +) +from custom_components.tibber_prices.coordinator.time_service import ( + TibberPricesTimeService, +) +from homeassistant.util import dt as dt_util + + +def _create_test_intervals_with_gap_issue() -> list[dict]: + """ + Create test data that demonstrates the gap issue. + + Pattern simulates a day where qualifying intervals appear in three separate + time windows, separated by non-qualifying intervals. + """ + now_local = dt_util.now() + base_time = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + + daily_min = 0.28 # Minimum at 12:00 + daily_avg = 0.32 + daily_max = 0.38 + + 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), + "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, + } + + intervals = [] + + # Period 1: Early morning (00:00-06:00) - CHEAP, qualifies + for hour in range(6): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.30, "CHEAP", "LOW")) + + # Gap 1: Morning peak (06:00-12:00) - NORMAL, doesn't qualify + for hour in range(6, 12): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.35, "NORMAL", "NORMAL")) + + # Period 2: Midday minimum (12:00-13:00) - VERY CHEAP, qualifies + for hour in range(12, 13): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.28, "VERY_CHEAP", "VERY_LOW")) + + # Gap 2: Afternoon rise (13:00-15:00) - NORMAL, doesn't qualify + for hour in range(13, 15): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.36, "NORMAL", "NORMAL")) + + # Period 3: Late afternoon dip (15:00-18:00) - CHEAP, qualifies (THIS IS THE BUG) + # This period should be included because price 0.29 is: + # - Within flex from daily minimum (0.28) + # - Below average - min_distance + # - Marked as CHEAP level + for hour in range(15, 18): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.29, "CHEAP", "LOW")) + + # Gap 3: Evening peak (18:00-24:00) - EXPENSIVE, doesn't qualify + for hour in range(18, 24): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.38, "EXPENSIVE", "HIGH")) + + return intervals + + +@pytest.mark.asyncio +async def test_best_price_includes_all_qualifying_periods(): + """Test that all qualifying periods are found, not just those before/at minimum.""" + # Setup + mock_coordinator = Mock() + mock_coordinator.config_entry = Mock() + time_service = TibberPricesTimeService(mock_coordinator) + + # Mock now() to return test date + test_time = dt_util.now() + time_service.now = Mock(return_value=test_time) + + intervals = _create_test_intervals_with_gap_issue() + + # Daily stats for verification + daily_min = 0.28 + daily_avg = 0.32 + + # Config: 20% flex, 5% min_distance, level filter = CHEAP or better + config = TibberPricesPeriodConfig( + reverse_sort=False, # Best price + flex=0.20, # 20% flex allows prices up to 0.28 * 1.20 = 0.336 + min_distance_from_avg=5.0, # Prices must be <= 0.32 * 0.95 = 0.304 + min_period_length=60, # 1 hour minimum + threshold_low=0.25, + threshold_high=0.30, + threshold_volatility_moderate=10.0, + threshold_volatility_high=20.0, + threshold_volatility_very_high=30.0, + level_filter="cheap", # Only CHEAP or better + gap_count=0, + ) + + # Calculate periods + result = calculate_periods(intervals, config=config, time=time_service) + periods = result["periods"] + + # Debug output + print(f"\nDaily stats: min={daily_min}, avg={daily_avg}") + print(f"Flex threshold: {daily_min * 1.20:.3f}") + print(f"Min distance threshold: {daily_avg * 0.95:.3f}") + print(f"\nFound {len(periods)} period(s):") + for i, period in enumerate(periods): + print(f" Period {i+1}: {period['start']} to {period['end']} ({period.get('length_minutes')}min)") + + # Expected periods: + # 1. 00:00-06:00 (price 0.30: within flex 0.336, below distance 0.304, CHEAP level) ✓ + # 2. 12:00-13:00 (price 0.28: within flex 0.336, below distance 0.304, VERY_CHEAP level) ✓ + # 3. 15:00-18:00 (price 0.29: within flex 0.336, below distance 0.304, CHEAP level) ✓ THIS ONE MIGHT BE MISSING + + # Assertions + assert len(periods) >= 2, f"Expected at least 2 periods, got {len(periods)}" + + # Check if third period is found (this is the bug fix target) + # If only 2 periods found, the late afternoon period (15:00-18:00) was skipped + if len(periods) == 2: + pytest.fail( + "BUG CONFIRMED: Only 2 periods found. " + "The late afternoon period (15:00-18:00, price 0.29) was skipped " + "even though it qualifies (CHEAP level, within flex and min_distance). " + "This demonstrates the issue where qualifying intervals AFTER the daily " + "minimum are not included in best price periods." + ) + + # If fix is working, we should have 3 periods + assert len(periods) == 3, ( + f"Expected 3 periods (early morning, midday, late afternoon), got {len(periods)}. " + f"Periods found: {[(p['start'], p['end']) for p in periods]}" + ) + + # Verify periods are at expected times + period_hours = [(p["start"].hour, p["end"].hour) for p in periods] + expected_ranges = [(0, 6), (12, 13), (15, 18)] + + for expected_start, expected_end in expected_ranges: + found = any(start <= expected_start < end or start < expected_end <= end for start, end in period_hours) + assert found, f"Expected period in range {expected_start}:00-{expected_end}:00 not found" + + +@pytest.mark.asyncio +async def test_best_price_with_relaxed_criteria(): + """Test with more relaxed criteria to ensure at least some periods are found.""" + mock_coordinator = Mock() + mock_coordinator.config_entry = Mock() + time_service = TibberPricesTimeService(mock_coordinator) + + # Mock now() to return test date + test_time = dt_util.now() + time_service.now = Mock(return_value=test_time) + + intervals = _create_test_intervals_with_gap_issue() + + # Very relaxed config to ensure periods are found + config = TibberPricesPeriodConfig( + reverse_sort=False, + flex=0.50, # 50% flex - very permissive + min_distance_from_avg=0.0, # Disabled + min_period_length=60, + threshold_low=0.25, + threshold_high=0.30, + threshold_volatility_moderate=10.0, + threshold_volatility_high=20.0, + threshold_volatility_very_high=30.0, + level_filter=None, # No level filter + gap_count=0, + ) + + result = calculate_periods(intervals, config=config, time=time_service) + periods = result["periods"] + + print(f"\nWith relaxed criteria: Found {len(periods)} period(s)") + for i, period in enumerate(periods): + print(f" Period {i+1}: {period['start']} to {period['end']}") + + # Even with very relaxed criteria, if we only get 2 periods, something is wrong + assert len(periods) > 0, "No periods found even with very relaxed criteria" diff --git a/tests/test_period_price_ordering_issue.py b/tests/test_period_price_ordering_issue.py new file mode 100644 index 0000000..a4fd3d4 --- /dev/null +++ b/tests/test_period_price_ordering_issue.py @@ -0,0 +1,261 @@ +""" +Test for period calculation issue where lower-priced intervals appear in wrong order. + +Issue (from problem statement): "I get the impression that sometimes, only prices +before the minimum daily price are considered while intervals after minimum price +should also be included because they are actually lower than intervals before minimum price." + +Key insight: The issue might be that intervals AFTER the minimum that are LOWER in price +than intervals BEFORE the minimum are not being selected for the period. + +Example scenario that might expose the bug: +- 00:00-06:00: Price 0.32 (NORMAL, doesn't qualify for best price) +- 06:00-12:00: Price 0.28 (CHEAP, daily MINIMUM - qualifies) +- 12:00-18:00: Price 0.30 (CHEAP, qualifies - LOWER than 00:00-06:00 but HIGHER than minimum) +- 18:00-24:00: Price 0.35 (NORMAL, doesn't qualify) + +If algorithm processes chronologically and breaks periods on non-qualifying intervals, +it should still work fine. But what if the issue is about PRICE ORDER not TIME ORDER? + +Let me try a different scenario - maybe the problem is with flex calculation +or reference price selection. +""" + +from __future__ import annotations + +from unittest.mock import Mock + +import pytest + +from custom_components.tibber_prices.coordinator.period_handlers import ( + TibberPricesPeriodConfig, + calculate_periods, +) +from custom_components.tibber_prices.coordinator.time_service import ( + TibberPricesTimeService, +) +from homeassistant.util import dt as dt_util + + +def _create_scenario_minimum_in_middle() -> list[dict]: + """ + Create scenario where daily minimum is in the middle of the day. + + The issue might be: If daily minimum is at 12:00, and there are qualifying + intervals both before AND after, are all of them included? + """ + now_local = dt_util.now() + base_time = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + + daily_min = 0.25 # Minimum at 12:00 + daily_avg = 0.31 + daily_max = 0.38 + + 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), + "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, + } + + intervals = [] + + # Morning: Moderate prices (00:00-06:00) - price 0.30 + # This is BELOW average but ABOVE minimum + for hour in range(6): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.30, "CHEAP", "LOW")) + + # Late morning: Higher prices (06:00-12:00) - price 0.33 + for hour in range(6, 12): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.33, "NORMAL", "NORMAL")) + + # Midday: Daily MINIMUM (12:00-13:00) - price 0.25 + for hour in range(12, 13): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.25, "VERY_CHEAP", "VERY_LOW")) + + # Afternoon: Back up slightly (13:00-18:00) - price 0.27 + # This is LOWER than morning (0.30) but HIGHER than minimum (0.25) + for hour in range(13, 18): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.27, "CHEAP", "LOW")) + + # Evening: High prices (18:00-24:00) - price 0.38 + for hour in range(18, 24): + for minute in [0, 15, 30, 45]: + intervals.append(_create_interval(hour, minute, 0.38, "EXPENSIVE", "HIGH")) + + return intervals + + +@pytest.mark.asyncio +async def test_periods_include_intervals_lower_than_morning_prices(): + """ + Test that afternoon intervals (price 0.27) are included even though + they're higher than the minimum (0.25) but lower than morning (0.30). + + Expected: Both morning (0.30) and afternoon (0.27) should be in periods + if flex/min_distance criteria allow. + """ + mock_coordinator = Mock() + mock_coordinator.config_entry = Mock() + time_service = TibberPricesTimeService(mock_coordinator) + + test_time = dt_util.now() + time_service.now = Mock(return_value=test_time) + + intervals = _create_scenario_minimum_in_middle() + + # Daily stats for verification + daily_min = 0.25 + daily_avg = 0.31 + + # Config with flex allowing prices up to 0.25 * 1.30 = 0.325 + # This should include: + # - Morning 0.30 ✓ (within flex, below avg) + # - Minimum 0.25 ✓ (within flex, below avg) + # - Afternoon 0.27 ✓ (within flex, below avg) + # - Late morning 0.33 ✗ (exceeds flex) + # - Evening 0.38 ✗ (exceeds flex) + + config = TibberPricesPeriodConfig( + reverse_sort=False, # Best price + flex=0.30, # 30% flex allows up to 0.325 + min_distance_from_avg=10.0, # Prices must be <= 0.31 * 0.90 = 0.279 + min_period_length=60, # 1 hour minimum + threshold_low=0.25, + threshold_high=0.30, + threshold_volatility_moderate=10.0, + threshold_volatility_high=20.0, + threshold_volatility_very_high=30.0, + level_filter=None, # No level filter + gap_count=0, + ) + + # Calculate periods + result = calculate_periods(intervals, config=config, time=time_service) + periods = result["periods"] + + # Debug output + print(f"\nDaily stats: min={daily_min}, avg={daily_avg}") + print(f"Flex threshold: {daily_min * 1.30:.3f}") + print(f"Min distance threshold: {daily_avg * 0.90:.3f}") + print(f"\nInterval prices:") + print(f" 00:00-06:00: 0.30 (should qualify if min_distance allows)") + print(f" 06:00-12:00: 0.33 (should NOT qualify - exceeds flex)") + print(f" 12:00-13:00: 0.25 (MINIMUM - should qualify)") + print(f" 13:00-18:00: 0.27 (should qualify - lower than morning)") + print(f" 18:00-24:00: 0.38 (should NOT qualify)") + print(f"\nFound {len(periods)} period(s):") + for i, period in enumerate(periods): + print(f" Period {i+1}: {period['start'].strftime('%H:%M')} to {period['end'].strftime('%H:%M')}") + + # With min_distance = 10%, threshold is 0.31 * 0.90 = 0.279 + # Morning (0.30) exceeds this threshold → should NOT be included + # Minimum (0.25) ✓ + # Afternoon (0.27) ✓ + + # Expected: 2 separate periods (minimum and afternoon) OR 1 combined period if adjacent + + assert len(periods) > 0, "Should find at least one period" + + # Check if afternoon period is found (13:00-18:00, price 0.27) + afternoon_period_found = any( + p["start"].hour <= 13 and p["end"].hour >= 16 + for p in periods + ) + + assert afternoon_period_found, ( + "Afternoon period (13:00-18:00, price 0.27) should be included. " + "It's lower than morning prices (0.30) and qualifies by both " + "flex (0.27 < 0.325) and min_distance (0.27 < 0.279) criteria." + ) + + +@pytest.mark.asyncio +async def test_strict_min_distance_filters_higher_before_minimum(): + """ + Test with strict min_distance that filters morning but keeps afternoon. + + This tests whether the algorithm correctly distinguishes between: + - Morning (0.30): Higher price, before minimum + - Afternoon (0.27): Lower price, after minimum + """ + mock_coordinator = Mock() + mock_coordinator.config_entry = Mock() + time_service = TibberPricesTimeService(mock_coordinator) + + test_time = dt_util.now() + time_service.now = Mock(return_value=test_time) + + intervals = _create_scenario_minimum_in_middle() + + # Strict config: min_distance=15% → threshold = 0.31 * 0.85 = 0.2635 + # This should filter morning (0.30) but keep afternoon (0.27) + + config = TibberPricesPeriodConfig( + reverse_sort=False, + flex=0.50, # Very permissive flex + min_distance_from_avg=15.0, # Strict: prices must be <= 0.2635 + min_period_length=60, + threshold_low=0.25, + threshold_high=0.30, + threshold_volatility_moderate=10.0, + threshold_volatility_high=20.0, + threshold_volatility_very_high=30.0, + level_filter=None, + gap_count=0, + ) + + # Calculate periods + result = calculate_periods(intervals, config=config, time=time_service) + periods = result["periods"] + + print(f"\n--- Strict min_distance test ---") + print(f"Config: flex={config.flex*100}%, min_distance={config.min_distance_from_avg}%") + print(f"Daily avg: {daily_avg}") + print(f"Nominal min distance threshold: {0.31 * 0.85:.4f}") + print(f"With flex={config.flex*100}% → scaled min_distance (approx): {config.min_distance_from_avg * 0.25}%") + print(f"Scaled threshold: {0.31 * (1 - (config.min_distance_from_avg * 0.25 / 100)):.4f}") + print(f"Morning 0.30: {'FAIL' if 0.30 > 0.2635 else 'PASS'} (vs nominal threshold)") + print(f"Minimum 0.25: {'FAIL' if 0.25 > 0.2635 else 'PASS'} (vs nominal threshold)") + print(f"Afternoon 0.27: {'FAIL' if 0.27 > 0.2635 else 'PASS'} (vs nominal threshold)") + print(f"\nFound {len(periods)} period(s):") + for i, period in enumerate(periods): + print(f" Period {i+1}: {period['start'].strftime('%H:%M')} to {period['end'].strftime('%H:%M')}") + + # Morning should be filtered out (0.30 > 0.2635) + # Minimum and afternoon should form period(s) + + assert len(periods) > 0, "Should find periods for minimum and afternoon" + + # Verify morning is NOT in periods + morning_period_found = any( + p["start"].hour == 0 + for p in periods + ) + + assert not morning_period_found, ( + "Morning period (0.30) should be filtered by strict min_distance. " + "Only minimum (0.25) and afternoon (0.27) should be in periods." + ) + + # Verify afternoon IS in periods + afternoon_period_found = any( + p["start"].hour <= 13 and p["end"].hour >= 16 + for p in periods + ) + + assert afternoon_period_found, ( + "Afternoon period (0.27) should be included - it's lower than morning " + "and passes the min_distance filter." + )