diff --git a/custom_components/tibber_prices/coordinator/period_handlers/core.py b/custom_components/tibber_prices/coordinator/period_handlers/core.py index 968af9e..d830095 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/core.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/core.py @@ -73,7 +73,7 @@ def calculate_periods( # Extract config values reverse_sort = config.reverse_sort - flex_raw = config.flex + flex_raw = config.flex # Already normalized to positive by get_period_config() min_distance_from_avg = config.min_distance_from_avg min_period_length = config.min_period_length threshold_low = config.threshold_low @@ -81,13 +81,14 @@ def calculate_periods( # CRITICAL: Hard cap flex at 50% to prevent degenerate behavior # Above 50%, period detection becomes unreliable (too many intervals qualify) + # NOTE: flex_raw is already positive from normalization in get_period_config() flex = flex_raw - if abs(flex_raw) > MAX_SAFE_FLEX: - flex = MAX_SAFE_FLEX if flex_raw > 0 else -MAX_SAFE_FLEX + if flex_raw > MAX_SAFE_FLEX: + flex = MAX_SAFE_FLEX _LOGGER.warning( "Flex %.1f%% exceeds maximum safe value! Capping at %.0f%%. " "Recommendation: Use 15-20%% with relaxation enabled, or 25-35%% without relaxation.", - abs(flex_raw) * 100, + flex_raw * 100, MAX_SAFE_FLEX * 100, ) diff --git a/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py b/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py index 40c1c08..d26f3e8 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/level_filtering.py @@ -109,6 +109,11 @@ def check_interval_criteria( """ Check if interval meets flex and minimum distance criteria. + CRITICAL: This function works with NORMALIZED values (always positive): + - criteria.flex: Always positive (e.g., 0.20 for 20%) + - criteria.min_distance_from_avg: Always positive (e.g., 5.0 for 5%) + - criteria.reverse_sort: Determines direction (True=Peak, False=Best) + Args: price: Interval price criteria: Interval criteria (ref_price, avg_price, flex, etc.) @@ -117,54 +122,51 @@ def check_interval_criteria( Tuple of (in_flex, meets_min_distance) """ - # CRITICAL: Handle negative reference prices correctly - # For best price (reverse_sort=False): ref_price is daily minimum - # For peak price (reverse_sort=True): ref_price is daily maximum + # Normalize inputs to absolute values for consistent calculation + flex_abs = abs(criteria.flex) + min_distance_abs = abs(criteria.min_distance_from_avg) + + # ============================================================ + # FLEX FILTER: Check if price is within flex threshold of reference + # ============================================================ + # Reference price is: + # - Peak price (reverse_sort=True): daily MAXIMUM + # - Best price (reverse_sort=False): daily MINIMUM # - # Flex determines price band: - # - Best price: [ref_price, ref_price + abs(ref_price) * flex] - # - Peak price: [ref_price - abs(ref_price) * flex, ref_price] + # Flex band calculation (using absolute values): + # - Peak price: [max - max*flex, max] → accept prices near the maximum + # - Best price: [min, min + min*flex] → accept prices near the minimum # - # Examples (flex=15%): - # Positive ref (10 ct, best): [10, 11.5] → max = 10 + 10*0.15 = 11.5 - # Negative ref (-10 ct, best): [-10, -8.5] → max = -10 + 10*0.15 = -8.5 (less negative = more expensive) - # Positive ref (30 ct, peak): [25.5, 30] → min = 30 - 30*0.15 = 25.5 - # Negative ref (-5 ct, peak): [-5.75, -5] → min = -5 - 5*0.15 = -5.75 (more negative = cheaper) + # Examples with flex=20%: + # - Peak: max=30 ct → accept [24, 30] ct (prices ≥ 24 ct) + # - Best: min=10 ct → accept [10, 12] ct (prices ≤ 12 ct) if criteria.ref_price == 0: # Zero reference: flex has no effect, use strict equality in_flex = price == 0 else: - # Calculate flex threshold using absolute value of reference - flex_amount = abs(criteria.ref_price) * criteria.flex + # Calculate flex amount using absolute value + flex_amount = abs(criteria.ref_price) * flex_abs if criteria.reverse_sort: - # Peak price: price must be >= (ref_price - flex_amount) - # For negative ref: more negative is cheaper, so subtract - # For positive ref: lower value is cheaper, so subtract - # Example: ref=30, flex=15% → accept [25.5, 30] → price >= 25.5 - # Example: ref=-5, flex=15% → accept [-5.75, -5] → price >= -5.75 + # Peak price: accept prices >= (ref_price - flex_amount) + # Prices must be CLOSE TO or AT the maximum flex_threshold = criteria.ref_price - flex_amount - in_flex = price >= flex_threshold and price <= criteria.ref_price + in_flex = price >= flex_threshold else: - # Best price: price must be in range [ref_price, ref_price + flex_amount] - # For negative ref: less negative is more expensive, so add - # For positive ref: higher value is more expensive, so add - # Example: ref=10, flex=15% → accept [10, 11.5] → 10 <= price <= 11.5 - # Example: ref=-10, flex=15% → accept [-10, -8.5] → -10 <= price <= -8.5 + # Best price: accept prices <= (ref_price + flex_amount) + # Prices must be CLOSE TO or AT the minimum flex_threshold = criteria.ref_price + flex_amount in_flex = price >= criteria.ref_price and price <= flex_threshold + # ============================================================ + # MIN_DISTANCE FILTER: Check if price is far enough from average + # ============================================================ # CRITICAL: Adjust min_distance dynamically based on flex to prevent conflicts # Problem: High flex (e.g., 50%) can conflict with fixed min_distance (e.g., 5%) # Solution: When flex is high, reduce min_distance requirement proportionally - # - # At low flex (≤20%), use full min_distance (e.g., 5%) - # At high flex (≥40%), reduce min_distance to avoid over-filtering - # Linear interpolation between 20-40% flex range - adjusted_min_distance = criteria.min_distance_from_avg - flex_abs = abs(criteria.flex) + adjusted_min_distance = min_distance_abs if flex_abs > FLEX_SCALING_THRESHOLD: # Scale down min_distance as flex increases @@ -173,7 +175,7 @@ def check_interval_criteria( # At 50% flex: multiplier = 0.25 (quarter min_distance) flex_excess = flex_abs - 0.20 # How much above 20% scale_factor = max(0.25, 1.0 - (flex_excess * 2.5)) # Linear reduction, min 25% - adjusted_min_distance = criteria.min_distance_from_avg * scale_factor + adjusted_min_distance = min_distance_abs * scale_factor # Log adjustment at DEBUG level (only when significant reduction) if scale_factor < SCALE_FACTOR_WARNING_THRESHOLD: @@ -183,18 +185,21 @@ def check_interval_criteria( _LOGGER.debug( "High flex %.1f%% detected: Reducing min_distance %.1f%% → %.1f%% (scale %.2f)", flex_abs * 100, - criteria.min_distance_from_avg, + min_distance_abs, adjusted_min_distance, scale_factor, ) - # Minimum distance from average (using adjusted value) - # Uniform formula: avg * (1 + distance/100) works for both Best (negative) and Peak (positive) - # - Best: distance=-5% → avg * 0.95 (5% below average) - # - Peak: distance=+5% → avg * 1.05 (5% above average) - min_distance_threshold = criteria.avg_price * (1 + adjusted_min_distance / 100) - - # Check: Peak (≥ threshold) or Best (≤ threshold) - meets_min_distance = price >= min_distance_threshold if criteria.reverse_sort else price <= min_distance_threshold + # Calculate threshold from average (using normalized positive distance) + # - Peak price: threshold = avg * (1 + distance/100) → prices must be ABOVE avg+distance + # - Best price: threshold = avg * (1 - distance/100) → prices must be BELOW avg-distance + if criteria.reverse_sort: + # Peak: price must be >= avg * (1 + distance%) + min_distance_threshold = criteria.avg_price * (1 + adjusted_min_distance / 100) + meets_min_distance = price >= min_distance_threshold + else: + # Best: price must be <= avg * (1 - distance%) + min_distance_threshold = criteria.avg_price * (1 - adjusted_min_distance / 100) + meets_min_distance = price <= min_distance_threshold return in_flex, meets_min_distance diff --git a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py index 86595e4..f231abb 100644 --- a/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py +++ b/custom_components/tibber_prices/coordinator/period_handlers/relaxation.py @@ -431,8 +431,9 @@ def relax_all_prices( # noqa: PLR0913 - Comprehensive filter relaxation require original_level_filter, ) + # NOTE: config.flex is already normalized to positive by get_period_config() relaxed_config = config._replace( - flex=current_flex if config.flex >= 0 else -current_flex, + flex=current_flex, # Already positive from normalization level_filter="any", ) diff --git a/custom_components/tibber_prices/coordinator/periods.py b/custom_components/tibber_prices/coordinator/periods.py index 0ffbae1..6d092e3 100644 --- a/custom_components/tibber_prices/coordinator/periods.py +++ b/custom_components/tibber_prices/coordinator/periods.py @@ -143,14 +143,30 @@ class TibberPricesPeriodCalculator: ) # Convert flex from percentage to decimal (e.g., 5 -> 0.05) + # CRITICAL: Normalize to absolute value for internal calculations + # User-facing values use sign convention: + # - Best price: positive (e.g., +15% above minimum) + # - Peak price: negative (e.g., -20% below maximum) + # Internal calculations always use positive values with reverse_sort flag try: - flex = float(flex) / 100 + flex = abs(float(flex)) / 100 # Always positive internally except (TypeError, ValueError): - flex = _const.DEFAULT_BEST_PRICE_FLEX / 100 if not reverse_sort else _const.DEFAULT_PEAK_PRICE_FLEX / 100 + flex = ( + abs(_const.DEFAULT_BEST_PRICE_FLEX) / 100 + if not reverse_sort + else abs(_const.DEFAULT_PEAK_PRICE_FLEX) / 100 + ) + + # CRITICAL: Normalize min_distance_from_avg to absolute value + # User-facing values use sign convention: + # - Best price: negative (e.g., -5% below average) + # - Peak price: positive (e.g., +5% above average) + # Internal calculations always use positive values with reverse_sort flag + min_distance_from_avg_normalized = abs(float(min_distance_from_avg)) config = { "flex": flex, - "min_distance_from_avg": float(min_distance_from_avg), + "min_distance_from_avg": min_distance_from_avg_normalized, "min_period_length": int(min_period_length), } diff --git a/tests/test_best_price_e2e.py b/tests/test_best_price_e2e.py new file mode 100644 index 0000000..55753a0 --- /dev/null +++ b/tests/test_best_price_e2e.py @@ -0,0 +1,376 @@ +""" +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 + """ + base_time = dt_util.parse_datetime("2025-11-22T00:00:00+01:00") + assert base_time is not None + + 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 +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, + ) + + periods = result.get("periods", []) + + # Validation: periods found + assert len(periods) > 0, "Best periods should generate" + assert 2 <= len(periods) <= 5, f"Expected 2-5 periods, 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, + ) + + periods_pos = result_pos.get("periods", []) + + # With positive flex, should find periods + assert len(periods_pos) >= 2, 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, + ) + + 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_avg", 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, relaxation_meta = 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, + ) + + periods = result.get("periods", []) + + # Should find periods via relaxation + assert len(periods) >= 2, "Relaxation should find periods" + + # Check if relaxation was used + 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 +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, relaxation_meta = 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, + ) + + # Check metadata from result + metadata = result.get("metadata", {}) + config_used = metadata.get("config", {}) + + if "flex" in config_used: + flex_used = config_used["flex"] + # Reasonable flex should be sufficient + assert 0.10 <= flex_used <= 0.35, f"Expected flex 10-35%, got {flex_used * 100:.1f}%" + + # Also check relaxation metadata + if "max_flex_used" in relaxation_meta: + max_flex = relaxation_meta["max_flex_used"] + assert max_flex <= 0.35, 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, + ) + + 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_avg", 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" + ) diff --git a/tests/test_level_filtering.py b/tests/test_level_filtering.py new file mode 100644 index 0000000..7936418 --- /dev/null +++ b/tests/test_level_filtering.py @@ -0,0 +1,685 @@ +""" +Unit tests for level_filtering.py - Filter logic for period calculation. + +This test suite validates the core filtering logic used in period calculation: +- Flex filter (price distance from daily min/max) +- Min distance filter (price distance from daily average) +- Dynamic scaling of min_distance when flex is high (>20%) +- Sign convention normalization (negative user values → positive internal values) + +Regression Tests: +- Peak Price Sign Convention Bug (Nov 2025): Negative flex values blocked all peak prices +- Redundant Condition Bug (Nov 2025): "price <= ref_price" blocked all peak prices +""" + +from __future__ import annotations + +import pytest + +from custom_components.tibber_prices.coordinator.period_handlers.level_filtering import ( + check_interval_criteria, +) +from custom_components.tibber_prices.coordinator.period_handlers.types import ( + TibberPricesIntervalCriteria, +) + + +@pytest.mark.unit +class TestFlexFilterBestPrice: + """Test flex filter logic for Best Price (reverse_sort=False).""" + + def test_interval_within_flex_threshold(self) -> None: + """Test interval that qualifies (price within flex threshold from minimum).""" + # Daily min = 10 ct, flex = 15% → accepts up to 11.5 ct + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, # Daily minimum + avg_price=20.0, + flex=0.15, # 15% flexibility + min_distance_from_avg=0.0, + reverse_sort=False, # Best Price mode + ) + + # Price 11.0 ct is within 10 + (10 * 0.15) = 11.5 ct + price = 11.0 + + in_flex, _meets_distance = check_interval_criteria(price, criteria) + + assert in_flex is True, "Interval within flex threshold should pass flex check" + + def test_interval_outside_flex_threshold(self) -> None: + """Test interval that fails (price outside flex threshold from minimum).""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, # Daily minimum + avg_price=20.0, + flex=0.15, # 15% flexibility + min_distance_from_avg=0.0, + reverse_sort=False, # Best Price mode + ) + + # Price 12.0 ct is outside 10 + (10 * 0.15) = 11.5 ct + price = 12.0 + + in_flex, _meets_distance = check_interval_criteria(price, criteria) + + assert in_flex is False, "Interval outside flex threshold should fail flex check" + + +@pytest.mark.unit +class TestFlexFilterPeakPrice: + """Test flex filter logic for Peak Price (reverse_sort=True).""" + + def test_interval_within_flex_threshold(self) -> None: + """Test interval that qualifies (price within flex threshold from maximum).""" + # Daily max = 50 ct, flex = 20% → accepts down to 40 ct + # NOTE: flex is passed as POSITIVE 0.20, not negative! + criteria = TibberPricesIntervalCriteria( + ref_price=50.0, # Daily maximum + avg_price=30.0, + flex=0.20, # 20% flexibility (positive internally!) + min_distance_from_avg=0.0, + reverse_sort=True, # Peak Price mode + ) + + # Price 45 ct is within 50 - (50 * 0.20) = 40 ct threshold + price = 45.0 + + in_flex, _meets_distance = check_interval_criteria(price, criteria) + + assert in_flex is True, "Interval within flex threshold should pass flex check" + + def test_interval_outside_flex_threshold(self) -> None: + """Test interval that fails (price outside flex threshold from maximum).""" + criteria = TibberPricesIntervalCriteria( + ref_price=50.0, # Daily maximum + avg_price=30.0, + flex=0.20, # 20% flexibility (positive internally!) + min_distance_from_avg=0.0, + reverse_sort=True, # Peak Price mode + ) + + # Price 38 ct is outside 50 - (50 * 0.20) = 40 ct threshold (too cheap!) + price = 38.0 + + in_flex, _meets_distance = check_interval_criteria(price, criteria) + + assert in_flex is False, "Interval outside flex threshold should fail flex check" + + def test_regression_bug_peak_price_sign_convention(self) -> None: + """ + Regression test for Peak Price Sign Convention Bug (Nov 2025). + + Bug: When flex was passed as negative value (e.g., -0.20 for peak price), + the flex filter would reject ALL intervals because: + - User-facing config: peak_price_flex = -20% (negative sign convention) + - Expected internal: 0.20 (positive, with reverse_sort=True for direction) + - Broken behavior: Used -0.20 directly → math was wrong + + Additionally, there was a redundant condition that blocked peak prices: + if reverse_sort: + in_flex = price >= ref_price + (ref_price * flex) + and price <= ref_price # ← This was the bug! + + This test ensures: + 1. Negative flex values are normalized to positive (abs()) + 2. No redundant conditions block valid peak price intervals + """ + # User-facing: -20%, internally normalized to +0.20 + criteria = TibberPricesIntervalCriteria( + ref_price=50.0, # Daily maximum + avg_price=30.0, + flex=0.20, # After normalization: abs(-0.20) = 0.20 + min_distance_from_avg=0.0, + reverse_sort=True, + ) + + # Price exactly at threshold: 50 - (50 * 0.20) = 40 ct + price_at_threshold = 40.0 + in_flex, _ = check_interval_criteria(price_at_threshold, criteria) + assert in_flex is True, "Boundary case should pass after normalization fix" + + # Price within threshold: 45 ct + price_within = 45.0 + in_flex, _ = check_interval_criteria(price_within, criteria) + assert in_flex is True, "Price within threshold should pass" + + # Price outside threshold (too cheap): 38 ct + price_outside = 38.0 + in_flex, _ = check_interval_criteria(price_outside, criteria) + assert in_flex is False, "Price outside threshold should fail" + + +@pytest.mark.unit +class TestMinDistanceFilter: + """Test min_distance_from_avg filter logic.""" + + def test_best_price_below_average(self) -> None: + """Test Best Price interval below average (passes min_distance check).""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.50, # High flex to not filter by flex + min_distance_from_avg=5.0, # 5% below average required (positive internally!) + reverse_sort=False, + ) + + # Price 18 ct is 10% below average (20 - 18) / 20 = 0.10 → passes 5% requirement + price = 18.0 + + _in_flex, meets_distance = check_interval_criteria(price, criteria) + + assert meets_distance is True, "Interval sufficiently below average should pass distance check" + + def test_best_price_too_close_to_average(self) -> None: + """Test Best Price interval too close to average (fails min_distance check).""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.10, # Low flex (10%) to avoid dynamic scaling + min_distance_from_avg=5.0, # 5% below average required + reverse_sort=False, + ) + + # Price 19.5 ct is only 2.5% below average → fails 5% requirement + # Threshold = 20 * (1 - 5/100) = 19.0 ct + # 19.5 ct > 19.0 ct → FAILS distance check + price = 19.5 + + _in_flex, meets_distance = check_interval_criteria(price, criteria) + + assert meets_distance is False, "Interval too close to average should fail distance check" + + def test_peak_price_above_average(self) -> None: + """Test Peak Price interval above average (passes min_distance check).""" + criteria = TibberPricesIntervalCriteria( + ref_price=50.0, + avg_price=30.0, + flex=0.50, # High flex to not filter by flex + min_distance_from_avg=5.0, # 5% above average required (positive internally!) + reverse_sort=True, + ) + + # Price 33 ct is 10% above average (33 - 30) / 30 = 0.10 → passes 5% requirement + price = 33.0 + + _in_flex, meets_distance = check_interval_criteria(price, criteria) + + assert meets_distance is True, "Interval sufficiently above average should pass distance check" + + def test_regression_min_distance_sign_convention(self) -> None: + """ + Regression test for min_distance sign convention (Nov 2025). + + Bug: min_distance_from_avg had sign convention issues similar to flex: + - User-facing: best_price_min_distance = -5% (negative = below average) + - User-facing: peak_price_min_distance = +5% (positive = above average) + - Expected internal: 5.0 (always positive, direction from reverse_sort) + + This test ensures min_distance is always normalized to positive. + """ + # Best Price: User-facing -5%, internally normalized to 5.0 + criteria_best = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.50, + min_distance_from_avg=5.0, # After normalization: abs(-5.0) = 5.0 + reverse_sort=False, + ) + + # 18 ct = 10% below average → passes 5% requirement + _, meets_distance = check_interval_criteria(18.0, criteria_best) + assert meets_distance is True, "Best price normalization works" + + # Peak Price: User-facing +5%, internally normalized to 5.0 + criteria_peak = TibberPricesIntervalCriteria( + ref_price=50.0, + avg_price=30.0, + flex=0.50, + min_distance_from_avg=5.0, # After normalization: abs(5.0) = 5.0 + reverse_sort=True, + ) + + # 33 ct = 10% above average → passes 5% requirement + _, meets_distance = check_interval_criteria(33.0, criteria_peak) + assert meets_distance is True, "Peak price normalization works" + + +@pytest.mark.unit +class TestDynamicScaling: + """Test dynamic scaling of min_distance when flex is high.""" + + def test_no_scaling_below_threshold(self) -> None: + """Test no scaling when flex <= 20% (threshold).""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.20, # Exactly at threshold + min_distance_from_avg=5.0, + reverse_sort=False, + ) + + # Price at exactly 5% below average + # Threshold = 20 * (1 - 5/100) = 19.0 ct + price = 19.0 + + _, meets_distance = check_interval_criteria(price, criteria) + + # At flex=20%, no scaling → full 5% requirement applies + assert meets_distance is True, "Boundary case should pass with no scaling" + + def test_scaling_at_30_percent_flex(self) -> None: + """Test dynamic scaling at flex=30% (scale factor ~0.75).""" + # flex=30% → excess=10% → scale_factor = 1.0 - (0.10 x 2.5) = 0.75 + # adjusted_min_distance = 5.0 x 0.75 = 3.75% + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.30, # 30% flex + min_distance_from_avg=5.0, + reverse_sort=False, + ) + + # Price at 4% below average + # Original threshold: 20 * (1 - 5/100) = 19.0 ct + # Scaled threshold: 20 * (1 - 3.75/100) = 19.25 ct + price = 19.2 # 4% below average + + _, meets_distance = check_interval_criteria(price, criteria) + + # With scaling, 4% below average passes (scaled requirement: 3.75%) + assert meets_distance is True, "Dynamic scaling should relax requirement" + + def test_scaling_at_50_percent_flex(self) -> None: + """Test maximum scaling at flex=50% (scale factor 0.25).""" + # flex=50% → excess=30% → scale_factor = max(0.25, 1.0 - 0.75) = 0.25 + # adjusted_min_distance = 5.0 x 0.25 = 1.25% + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.50, # Maximum safe flex + min_distance_from_avg=5.0, + reverse_sort=False, + ) + + # Price at 2% below average (would fail without scaling) + # Original threshold: 20 * (1 - 5/100) = 19.0 ct + # Scaled threshold: 20 * (1 - 1.25/100) = 19.75 ct + price = 19.6 # 2% below average + + _, meets_distance = check_interval_criteria(price, criteria) + + # With maximum scaling, 2% below average passes (scaled requirement: 1.25%) + assert meets_distance is True, "Maximum scaling should heavily relax requirement" + + def test_scaling_never_below_25_percent(self) -> None: + """Test that scale factor never goes below 0.25 (25%).""" + # Even with unrealistically high flex, min 25% of min_distance is enforced + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.80, # Unrealistically high flex (would be capped elsewhere) + min_distance_from_avg=5.0, + reverse_sort=False, + ) + + # Minimum scaled distance: 5.0 x 0.25 = 1.25% + # Threshold: 20 * (1 - 1.25/100) = 19.75 ct + price_fail = 19.8 # 1% below average (fails even with max scaling) + price_pass = 19.7 # 1.5% below average (passes) + + _, meets_distance_fail = check_interval_criteria(price_fail, criteria) + _, meets_distance_pass = check_interval_criteria(price_pass, criteria) + + assert meets_distance_fail is False, "Below minimum scaled threshold should fail" + assert meets_distance_pass is True, "Above minimum scaled threshold should pass" + + +@pytest.mark.unit +class TestBoundaryConditions: + """Test boundary and edge cases.""" + + def test_price_exactly_at_ref_price_best(self) -> None: + """Test Best Price: interval exactly at reference price (daily minimum).""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.15, + min_distance_from_avg=0.0, + reverse_sort=False, + ) + + # Price exactly at daily minimum + price = 10.0 + + in_flex, _ = check_interval_criteria(price, criteria) + + assert in_flex is True, "Price at reference should pass" + + def test_price_exactly_at_ref_price_peak(self) -> None: + """Test Peak Price: interval exactly at reference price (daily maximum).""" + criteria = TibberPricesIntervalCriteria( + ref_price=50.0, + avg_price=30.0, + flex=0.20, + min_distance_from_avg=0.0, + reverse_sort=True, + ) + + # Price exactly at daily maximum + price = 50.0 + + in_flex, _ = check_interval_criteria(price, criteria) + + assert in_flex is True, "Price at reference should pass" + + def test_price_exactly_at_flex_threshold_best(self) -> None: + """Test Best Price: interval exactly at flex threshold.""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.15, # 15% → accepts up to 11.5 ct + min_distance_from_avg=0.0, + reverse_sort=False, + ) + + # Price exactly at threshold: 10 + (10 * 0.15) = 11.5 ct + price = 11.5 + + in_flex, _ = check_interval_criteria(price, criteria) + + assert in_flex is True, "Price at flex threshold should pass" + + def test_price_exactly_at_flex_threshold_peak(self) -> None: + """Test Peak Price: interval exactly at flex threshold.""" + criteria = TibberPricesIntervalCriteria( + ref_price=50.0, + avg_price=30.0, + flex=0.20, # 20% → accepts down to 40 ct + min_distance_from_avg=0.0, + reverse_sort=True, + ) + + # Price exactly at threshold: 50 - (50 * 0.20) = 40 ct + price = 40.0 + + in_flex, _ = check_interval_criteria(price, criteria) + + assert in_flex is True, "Price at flex threshold should pass" + + def test_price_one_cent_outside_flex_threshold_best(self) -> None: + """Test Best Price: interval one cent outside flex threshold.""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.15, # Accepts up to 11.5 ct + min_distance_from_avg=0.0, + reverse_sort=False, + ) + + # Price one cent over threshold + price = 11.51 + + in_flex, _ = check_interval_criteria(price, criteria) + + assert in_flex is False, "Price over threshold should fail" + + def test_price_one_cent_outside_flex_threshold_peak(self) -> None: + """Test Peak Price: interval one cent outside flex threshold.""" + criteria = TibberPricesIntervalCriteria( + ref_price=50.0, + avg_price=30.0, + flex=0.20, # Accepts down to 40 ct + min_distance_from_avg=0.0, + reverse_sort=True, + ) + + # Price one cent below threshold (too cheap) + price = 39.99 + + in_flex, _ = check_interval_criteria(price, criteria) + + assert in_flex is False, "Price below threshold should fail" + + def test_zero_flex(self) -> None: + """Test with zero flexibility (only exact reference price passes).""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.0, # Zero flexibility + min_distance_from_avg=0.0, + reverse_sort=False, + ) + + # Only exact reference price should pass + price_exact = 10.0 + price_above = 10.01 + + in_flex_exact, _ = check_interval_criteria(price_exact, criteria) + in_flex_above, _ = check_interval_criteria(price_above, criteria) + + assert in_flex_exact is True, "Exact price should pass with zero flex" + assert in_flex_above is False, "Above reference should fail with zero flex" + + def test_zero_min_distance(self) -> None: + """Test with zero min_distance (any price passes distance check).""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.50, + min_distance_from_avg=0.0, # Zero min_distance + reverse_sort=False, + ) + + # Price exactly at average (would normally fail distance check) + price = 20.0 + + _, meets_distance = check_interval_criteria(price, criteria) + + # With zero min_distance, any price passes distance check + assert meets_distance is True, "Zero min_distance should accept all prices" + + def test_price_exactly_at_average_best(self) -> None: + """Test Best Price: interval exactly at average (fails with min_distance>0).""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.50, + min_distance_from_avg=5.0, # Requires 5% below average + reverse_sort=False, + ) + + # Price exactly at average + price = 20.0 + + _, meets_distance = check_interval_criteria(price, criteria) + + assert meets_distance is False, "Price at average should fail distance check" + + def test_price_exactly_at_average_peak(self) -> None: + """Test Peak Price: interval exactly at average (fails with min_distance>0).""" + criteria = TibberPricesIntervalCriteria( + ref_price=50.0, + avg_price=30.0, + flex=0.50, + min_distance_from_avg=5.0, # Requires 5% above average + reverse_sort=True, + ) + + # Price exactly at average + price = 30.0 + + _, meets_distance = check_interval_criteria(price, criteria) + + assert meets_distance is False, "Price at average should fail distance check" + + +@pytest.mark.unit +class TestCombinedFilters: + """Test interaction between flex and min_distance filters.""" + + def test_passes_flex_fails_distance(self) -> None: + """Test interval that passes flex but fails min_distance.""" + # Setup: We need flex threshold WIDER than distance threshold + # Use flex <= 20% to avoid dynamic scaling interference + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=13.0, # Closer average makes distance threshold tighter + flex=0.20, # Flex threshold: 10 + (10 * 0.20) = 12 ct + min_distance_from_avg=10.0, # Distance threshold: 13 * (1 - 10/100) = 11.7 ct + reverse_sort=False, + ) + + # Price 11.8 ct: passes flex (11.8 <= 12) but fails distance (11.8 > 11.7) + price = 11.8 + + in_flex, meets_distance = check_interval_criteria(price, criteria) + + assert in_flex is True, "Should pass flex (within 12 ct threshold)" + assert meets_distance is False, "Should fail distance (above 11.7 ct threshold)" + + def test_fails_flex_passes_distance(self) -> None: + """Test interval that fails flex but passes min_distance.""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, # Daily min + avg_price=20.0, + flex=0.15, # Low flex - accepts up to 11.5 ct + min_distance_from_avg=5.0, # Requires 5% below average (19 ct or less) + reverse_sort=False, + ) + + # Price 12 ct fails flex (> 11.5 ct) but passes distance (40% below avg) + price = 12.0 + + in_flex, meets_distance = check_interval_criteria(price, criteria) + + assert in_flex is False, "Should fail flex (outside 11.5 ct threshold)" + assert meets_distance is True, "Should pass distance (well below average)" + + def test_both_filters_pass(self) -> None: + """Test interval that passes both filters.""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.20, # Accepts up to 12 ct + min_distance_from_avg=5.0, # Requires 5% below average (19 ct or less) + reverse_sort=False, + ) + + # Price 11 ct passes both: < 12 ct (flex) and < 19 ct (distance) + price = 11.0 + + in_flex, meets_distance = check_interval_criteria(price, criteria) + + assert in_flex is True, "Should pass flex" + assert meets_distance is True, "Should pass distance" + + def test_both_filters_fail(self) -> None: + """Test interval that fails both filters.""" + criteria = TibberPricesIntervalCriteria( + ref_price=10.0, + avg_price=20.0, + flex=0.15, # Accepts up to 11.5 ct + min_distance_from_avg=5.0, # Requires 5% below average (19 ct or less) + reverse_sort=False, + ) + + # Price 19.5 ct fails both: > 11.5 ct (flex) and > 19 ct (distance) + price = 19.5 + + in_flex, meets_distance = check_interval_criteria(price, criteria) + + assert in_flex is False, "Should fail flex" + assert meets_distance is False, "Should fail distance" + + +@pytest.mark.unit +class TestRealWorldScenarios: + """Test with realistic price data and configurations.""" + + def test_german_market_best_price(self) -> None: + """Test Best Price with realistic German market data (Nov 2025).""" + # Real data from Nov 22, 2025: Min=0.17 ct, Avg=8.26 ct, Max=17.24 ct + criteria = TibberPricesIntervalCriteria( + ref_price=0.17, # Daily minimum (early morning) + avg_price=8.26, # Daily average + flex=0.15, # 15% default flex + min_distance_from_avg=5.0, # -5% below average (user-facing) + reverse_sort=False, + ) + + # Calculate thresholds: + # Flex threshold: 0.17 + (0.17 * 0.15) = 0.17 + 0.0255 = 0.1955 ct + # Distance threshold: 8.26 * (1 - 5/100) = 8.26 * 0.95 = 7.847 ct + + # Price scenarios + price_at_min = 0.17 # Should pass both (at minimum) + price_within_flex = 0.19 # Should pass flex (< 0.1955) + price_too_high = 5.0 # Should fail flex (> 0.1955), but pass distance (< 7.847) + + in_flex_min, meets_dist_min = check_interval_criteria(price_at_min, criteria) + in_flex_within, meets_dist_within = check_interval_criteria(price_within_flex, criteria) + in_flex_high, meets_dist_high = check_interval_criteria(price_too_high, criteria) + + assert in_flex_min is True, "Minimum price should pass flex" + assert meets_dist_min is True, "Minimum price should pass distance" + + assert in_flex_within is True, "0.19 ct should pass flex (< 0.1955)" + assert meets_dist_within is True, "0.19 ct should pass distance (< 7.847)" + + assert in_flex_high is False, "5 ct should fail flex (way above 0.1955 threshold)" + assert meets_dist_high is True, "5 ct should pass distance (< 7.847)" + + def test_german_market_peak_price(self) -> None: + """Test Peak Price with realistic German market data (Nov 2025).""" + # Real data: Min=0.17 ct, Avg=8.26 ct, Max=17.24 ct + criteria = TibberPricesIntervalCriteria( + ref_price=17.24, # Daily maximum (evening peak) + avg_price=8.26, # Daily average + flex=0.20, # 20% default flex (user-facing: -20%) + min_distance_from_avg=5.0, # +5% above average (user-facing) + reverse_sort=True, + ) + + # Calculate thresholds: + # Flex threshold: 17.24 - (17.24 * 0.20) = 17.24 - 3.448 = 13.792 ct + # Distance threshold: 8.26 * (1 + 5/100) = 8.26 * 1.05 = 8.673 ct + + # Price scenarios + price_at_max = 17.24 # Should pass both (at maximum) + price_within_flex = 14.0 # Should pass flex (> 13.792) + price_too_low = 10.0 # Should fail flex (< 13.792) + + in_flex_max, meets_dist_max = check_interval_criteria(price_at_max, criteria) + in_flex_within, meets_dist_within = check_interval_criteria(price_within_flex, criteria) + in_flex_low, meets_dist_low = check_interval_criteria(price_too_low, criteria) + + assert in_flex_max is True, "Maximum price should pass flex" + assert meets_dist_max is True, "Maximum price should pass distance" + + assert in_flex_within is True, "14 ct should pass flex (> 13.792)" + assert meets_dist_within is True, "14 ct should pass distance (> 8.673)" + + assert in_flex_low is False, "10 ct should fail flex (< 13.792 threshold)" + # 10 ct is still above average (8.26), so should pass distance + assert meets_dist_low is True, "10 ct should pass distance (> 8.673)" + + def test_negative_prices(self) -> None: + """Test with negative prices (wind/solar surplus scenarios).""" + # Scenario: Lots of renewable energy, prices go negative + criteria = TibberPricesIntervalCriteria( + ref_price=-5.0, # Daily minimum (negative!) + avg_price=3.0, # Daily average (some hours still positive) + flex=0.20, # 20% flex + min_distance_from_avg=5.0, + reverse_sort=False, + ) + + # Price at -5 ct (minimum, negative) + # Flex threshold: -5 + abs(-5 * 0.20) = -5 + 1 = -4 ct + price_at_min = -5.0 + price_within_flex = -4.5 + + in_flex_min, _ = check_interval_criteria(price_at_min, criteria) + in_flex_within, _ = check_interval_criteria(price_within_flex, criteria) + + assert in_flex_min is True, "Negative minimum should pass" + assert in_flex_within is True, "Within flex of negative min should pass" diff --git a/tests/test_peak_price_e2e.py b/tests/test_peak_price_e2e.py new file mode 100644 index 0000000..a5b91b2 --- /dev/null +++ b/tests/test_peak_price_e2e.py @@ -0,0 +1,381 @@ +""" +End-to-End Tests for Peak Price Period Generation (Nov 2025 Bug Fix). + +These tests validate that the sign convention bug fix works correctly: +- Bug: Negative flex (-20%) wasn't normalized → 100% FLEX filtering +- Fix: abs() normalization in periods.py + removed redundant condition + +Test coverage matches manual testing checklist: +1. ✅ Peak periods generate (not 0) +2. ✅ FLEX filter stats reasonable (~40-50%, 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 + """ + base_time = dt_util.parse_datetime("2025-11-22T00:00:00+01:00") + assert base_time is not None + + 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 +class TestPeakPriceGenerationWorks: + """Validate that peak price periods generate successfully after bug fix.""" + + def test_peak_periods_generate_successfully(self) -> None: + """ + ✅ PRIMARY TEST: Peak periods generate (not 0 like the bug). + + Bug: 192/192 intervals filtered by FLEX (100%) → 0 periods + Fix: Negative flex normalized → periods generate + """ + 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 with normalized positive flex (simulating fix) + config = TibberPricesPeriodConfig( + flex=0.20, # 20% positive (after abs() normalization) + min_distance_from_avg=5.0, + min_period_length=30, + reverse_sort=True, # Peak price mode + ) + + # 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, + ) + + periods = result.get("periods", []) + + # Bug validation: periods found (not 0) + assert len(periods) > 0, "Peak periods should generate after bug fix" + assert 2 <= len(periods) <= 5, f"Expected 2-5 periods, got {len(periods)}" + + def test_negative_flex_normalization_effect(self) -> None: + """ + ✅ TEST: Positive flex (normalized) produces periods. + + Bug: Would use negative flex (-20%) directly in math → 100% FLEX filter + Fix: abs() ensures positive flex → reasonable filtering + """ + 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 (simulates normalized result) + config_positive = TibberPricesPeriodConfig( + flex=0.20, # Positive after normalization + min_distance_from_avg=5.0, + min_period_length=30, + reverse_sort=True, + ) + + 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, + ) + + periods_pos = result_pos.get("periods", []) + + # With normalized positive flex, should find periods + assert len(periods_pos) >= 2, f"Should find periods with positive flex, got {len(periods_pos)}" + + def test_periods_contain_high_prices(self) -> None: + """ + ✅ TEST: Peak periods contain high prices (not cheap ones). + + Validates periods include expensive intervals, not cheap 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.20, + min_distance_from_avg=5.0, + min_period_length=30, + reverse_sort=True, + ) + + 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, + ) + + periods = result.get("periods", []) + + daily_min = intervals[0]["daily_min"] + + # Check period averages are NOT near daily minimum + for period in periods: + period_avg = period.get("price_avg", 0) + assert period_avg > daily_min * 1.05, ( + f"Peak period has too low avg: {period_avg:.4f} vs daily_min={daily_min:.4f}" + ) + + 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.15, # 15% - may need relaxation + min_distance_from_avg=5.0, + min_period_length=30, + reverse_sort=True, + ) + + result, relaxation_meta = 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, + ) + + periods = result.get("periods", []) + + # Should find periods via relaxation + assert len(periods) >= 2, "Relaxation should find periods" + + # Check if relaxation was used + if "max_flex_used" in relaxation_meta: + max_flex_used = relaxation_meta["max_flex_used"] + # Bug would need ~50% flex + # Fix: reasonable flex (15-35%) is sufficient + assert max_flex_used <= 0.35, f"Flex should stay reasonable, got {max_flex_used * 100:.1f}%" + + +@pytest.mark.unit +class TestBugRegressionValidation: + """Regression tests for the Nov 2025 sign convention bug.""" + + def test_metadata_shows_reasonable_flex_used(self) -> None: + """ + ✅ REGRESSION: Metadata shows flex used was reasonable (not 50%). + + This indirectly validates FLEX filter didn't block everything. + """ + 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.20, + min_distance_from_avg=5.0, + min_period_length=30, + reverse_sort=True, + ) + + result, relaxation_meta = 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, + ) + + # Check metadata from result + metadata = result.get("metadata", {}) + config_used = metadata.get("config", {}) + + if "flex" in config_used: + flex_used = config_used["flex"] + # Bug would need ~50% flex to find anything + # Fix: reasonable flex (~20-30%) is sufficient + assert 0.15 <= flex_used <= 0.35, ( + f"Expected flex 15-35%, got {flex_used * 100:.1f}% (Bug would require near 50%)" + ) + + # Also check relaxation metadata + if "max_flex_used" in relaxation_meta: + max_flex = relaxation_meta["max_flex_used"] + assert max_flex <= 0.35, f"Max flex should be reasonable, got {max_flex * 100:.1f}%" + + def test_periods_include_expensive_intervals(self) -> None: + """ + ✅ REGRESSION: Peak periods include intervals near daily max. + + Bug had redundant condition: price >= ref AND price <= ref + Fix: Removed redundant condition → high prices included + """ + 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.20, + min_distance_from_avg=5.0, + min_period_length=30, + reverse_sort=True, + ) + + 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, + ) + + periods = result.get("periods", []) + + daily_avg = intervals[0]["daily_avg"] + daily_max = intervals[0]["daily_max"] + + # At least one period should have high average + max_period_avg = max(p.get("price_avg", 0) for p in periods) + + assert max_period_avg >= daily_avg * 1.05, ( + f"Peak periods should have high avg: {max_period_avg:.4f} vs daily_avg={daily_avg:.4f}" + ) + + # Check proximity to daily max + assert max_period_avg >= daily_max * 0.85, ( + f"At least one period near daily_max: {max_period_avg:.4f} vs daily_max={daily_max:.4f}" + )