""" Comprehensive tests for price calculations with positive, negative, and zero prices. This test file uses parametrized tests to ensure all calculation functions handle ALL price scenarios correctly: - Positive prices (normal operation) - Negative prices (Norway/Germany renewable surplus) - Zero prices (rare but possible edge case) - Mixed scenarios (transitions, extreme volatility) """ from __future__ import annotations from datetime import UTC, datetime, timedelta import pytest from custom_components.tibber_prices.coordinator.time_service import ( TibberPricesTimeService, ) from custom_components.tibber_prices.utils.price import ( aggregate_period_levels, aggregate_period_ratings, aggregate_price_levels, aggregate_price_rating, calculate_difference_percentage, calculate_rating_level, calculate_trailing_average_for_interval, calculate_volatility_level, enrich_price_info_with_differences, ) # ============================================================================= # Volatility Calculation (Coefficient of Variation) - Parametrized # ============================================================================= @pytest.mark.parametrize( ("prices", "expected_level", "description"), [ # Positive prices - LOW volatility ([10.0, 11.0, 10.5, 10.2], "LOW", "stable positive prices"), # Negative prices - LOW volatility ([-10.0, -11.0, -10.5, -10.2], "LOW", "stable negative prices"), # Zero and near-zero - VERY_HIGH volatility (CV explodes with mean near zero) ([0.0, 0.1, -0.1, 0.05], "VERY_HIGH", "prices near zero (extreme CV)"), # Positive prices - MODERATE volatility ([10.0, 15.0, 12.0, 13.0], "MODERATE", "moderate positive variation"), # Negative prices - MODERATE volatility ([-10.0, -15.0, -12.0, -13.0], "MODERATE", "moderate negative variation"), # Mixed crossing zero - VERY_HIGH volatility (mean near zero → extreme CV) ([-2.0, 0.0, 2.0, 1.0], "VERY_HIGH", "mixed prices crossing zero (extreme CV)"), # Positive prices - HIGH volatility ([10.0, 20.0, 12.0, 18.0], "HIGH", "high positive variation"), # Negative prices - HIGH volatility ([-10.0, -20.0, -12.0, -18.0], "HIGH", "high negative variation"), # Mixed with larger spread - VERY_HIGH volatility (mean near zero) ([-5.0, 5.0, -3.0, 4.0], "VERY_HIGH", "high mixed variation (mean near zero)"), # Positive prices - VERY_HIGH volatility ([10.0, 40.0, 15.0, 35.0], "VERY_HIGH", "very high positive variation"), # Negative prices - VERY_HIGH volatility ([-10.0, -40.0, -15.0, -35.0], "VERY_HIGH", "very high negative variation"), # Extreme mixed ([-20.0, 20.0, -15.0, 18.0], "VERY_HIGH", "extreme mixed variation"), ], ) def test_volatility_level_scenarios( prices: list[float], expected_level: str, description: str, ) -> None: """Test volatility calculation across positive, negative, and mixed price scenarios.""" level = calculate_volatility_level(prices) assert level == expected_level, f"Failed for {description}: expected {expected_level}, got {level}" @pytest.mark.parametrize( ("prices", "expected_level", "description"), [ # Edge cases ([10.0], "LOW", "single positive value"), ([-10.0], "LOW", "single negative value"), ([0.0], "LOW", "single zero value"), ([], "LOW", "empty list"), ([0.0, 0.0, 0.0], "LOW", "all zeros (no variation)"), ([-5.0, -5.0], "LOW", "identical negative prices"), ], ) def test_volatility_level_edge_cases( prices: list[float], expected_level: str, description: str, ) -> None: """Test volatility edge cases that should always return LOW.""" level = calculate_volatility_level(prices) assert level == expected_level, f"Failed for {description}" def test_volatility_level_custom_thresholds() -> None: """Test volatility with custom thresholds works for all price types.""" # Test positive prices positive_result = calculate_volatility_level( [10.0, 12.5, 11.0, 13.0], threshold_moderate=10.0, threshold_high=25.0, threshold_very_high=50.0, ) assert positive_result == "MODERATE" # Test negative prices negative_result = calculate_volatility_level( [-10.0, -12.5, -11.0, -13.0], threshold_moderate=10.0, threshold_high=25.0, threshold_very_high=50.0, ) assert negative_result == "MODERATE" # ============================================================================= # Trailing Average Calculation - Parametrized # ============================================================================= @pytest.mark.parametrize( ("price_value", "expected_avg", "description"), [ (10.0, 10.0, "positive prices"), (-10.0, -10.0, "negative prices"), (0.0, 0.0, "zero prices"), ], ) def test_trailing_average_full_24h_data( price_value: float, expected_avg: float, description: str, ) -> None: """Test trailing average with full 24h data across different price scenarios.""" base_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) intervals = [ { "startsAt": base_time - timedelta(hours=24) + timedelta(minutes=15 * i), "total": price_value, } for i in range(96) ] avg = calculate_trailing_average_for_interval(base_time, intervals) assert avg == pytest.approx(expected_avg, rel=1e-9), f"Failed for {description}" def test_trailing_average_mixed_prices() -> None: """Test trailing average with mixed positive/negative/zero prices.""" base_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) # Mix: negative night, zero transition, positive day intervals = [] for i in range(96): hour = i // 4 # 0-23 if hour < 6: # Night: negative price = -5.0 elif hour < 8: # Morning transition: zero price = 0.0 else: # Day: positive price = 10.0 intervals.append( { "startsAt": base_time - timedelta(hours=24) + timedelta(minutes=15 * i), "total": price, } ) avg = calculate_trailing_average_for_interval(base_time, intervals) # 24 intervals * -5 + 8 intervals * 0 + 64 intervals * 10 = -120 + 0 + 640 = 520 # 520 / 96 ≈ 5.42 assert avg is not None assert avg == pytest.approx(5.42, rel=0.01) def test_trailing_average_no_data() -> None: """Test trailing average with no matching data.""" base_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) intervals = [ { "startsAt": base_time + timedelta(minutes=15 * i), "total": 10.0, } for i in range(96) ] avg = calculate_trailing_average_for_interval(base_time, intervals) assert avg is None def test_trailing_average_boundary_inclusive() -> None: """Test lookback window boundaries with various price types.""" base_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) intervals = [ # Exactly 24h before → INCLUDED {"startsAt": base_time - timedelta(hours=24), "total": 5.0}, # Negative price → INCLUDED {"startsAt": base_time - timedelta(hours=23, minutes=45), "total": -10.0}, # Zero price → INCLUDED {"startsAt": base_time - timedelta(hours=23, minutes=30), "total": 0.0}, # Exactly at base_time → EXCLUDED {"startsAt": base_time, "total": 100.0}, ] # Should average 5.0, -10.0, 0.0 only (excludes 100.0) # (5 + (-10) + 0) / 3 = -5 / 3 ≈ -1.67 avg = calculate_trailing_average_for_interval(base_time, intervals) assert avg == pytest.approx(-1.67, rel=0.01) def test_trailing_average_missing_fields() -> None: """Test trailing average with missing startsAt or total fields.""" base_time = datetime(2025, 11, 22, 14, 30, 0, tzinfo=UTC) intervals = [ # Missing startsAt → skipped {"total": 10.0}, # Missing total → skipped {"startsAt": base_time - timedelta(hours=1)}, # Valid negative price {"startsAt": base_time - timedelta(hours=2), "total": -15.0}, # total=None → skipped {"startsAt": base_time - timedelta(hours=3), "total": None}, # Valid zero price {"startsAt": base_time - timedelta(hours=4), "total": 0.0}, ] # Only -15.0 and 0.0 are valid → average = -7.5 avg = calculate_trailing_average_for_interval(base_time, intervals) assert avg == pytest.approx(-7.5, rel=1e-9) # ============================================================================= # Difference Percentage Calculation - Parametrized # ============================================================================= @pytest.mark.parametrize( ("current", "average", "expected_diff", "description"), [ # Positive prices (15.0, 10.0, 50.0, "positive current above positive average"), (8.0, 10.0, -20.0, "positive current below positive average"), (10.0, 10.0, 0.0, "positive current equals positive average"), # Negative prices (critical for Norway/Germany) (-8.0, -10.0, 20.0, "negative current above (less negative) than negative average"), (-12.0, -10.0, -20.0, "negative current below (more negative) than negative average"), (-10.0, -10.0, 0.0, "negative current equals negative average"), # Mixed scenarios (5.0, -10.0, 150.0, "positive current vs negative average"), (-5.0, 10.0, -150.0, "negative current vs positive average"), # Zero scenarios (5.0, 0.0, None, "positive current vs zero average (undefined)"), (-5.0, 0.0, None, "negative current vs zero average (undefined)"), (0.0, 10.0, -100.0, "zero current vs positive average"), (0.0, -10.0, 100.0, "zero current vs negative average"), (0.0, 0.0, None, "zero current vs zero average (undefined)"), ], ) def test_difference_percentage_scenarios( current: float, average: float | None, expected_diff: float | None, description: str, ) -> None: """Test difference percentage calculation across all price scenarios.""" diff = calculate_difference_percentage(current, average) if expected_diff is None: assert diff is None, f"Failed for {description}: expected None, got {diff}" else: assert diff is not None, f"Failed for {description}: expected {expected_diff}, got None" assert diff == pytest.approx(expected_diff, rel=1e-9), f"Failed for {description}" def test_difference_percentage_none_average() -> None: """Test difference when average is None.""" assert calculate_difference_percentage(15.0, None) is None assert calculate_difference_percentage(-15.0, None) is None assert calculate_difference_percentage(0.0, None) is None # ============================================================================= # Rating Level Calculation - Parametrized # ============================================================================= @pytest.mark.parametrize( ("difference", "expected_rating", "description"), [ # Positive difference scenarios (-15.0, "LOW", "well below average"), (-10.0, "LOW", "at low threshold (boundary)"), (-5.0, "NORMAL", "slightly below average"), (0.0, "NORMAL", "at average"), (5.0, "NORMAL", "slightly above average"), (10.0, "HIGH", "at high threshold (boundary)"), (15.0, "HIGH", "well above average"), # Extreme values (-50.0, "LOW", "extremely below average"), (50.0, "HIGH", "extremely above average"), ], ) def test_rating_level_scenarios( difference: float, expected_rating: str, description: str, ) -> None: """Test rating level calculation with standard thresholds.""" rating = calculate_rating_level(difference, threshold_low=-10.0, threshold_high=10.0) assert rating == expected_rating, f"Failed for {description}" def test_rating_level_none_difference() -> None: """Test rating when difference is None (e.g., zero average).""" rating = calculate_rating_level(None, threshold_low=-10.0, threshold_high=10.0) assert rating is None # ============================================================================= # Price Enrichment Integration - Parametrized # ============================================================================= @pytest.mark.parametrize( ("day_before_yesterday_price", "yesterday_price", "today_price", "expected_diff", "expected_rating", "description"), [ # Positive prices (10.0, 10.0, 15.0, 50.0, "HIGH", "positive prices: day more expensive"), (15.0, 15.0, 10.0, -33.33, "LOW", "positive prices: day cheaper"), (10.0, 10.0, 10.0, 0.0, "NORMAL", "positive prices: stable"), # Negative prices (Norway/Germany scenario) (-10.0, -10.0, -15.0, -50.0, "LOW", "negative prices: day more negative (cheaper)"), (-15.0, -15.0, -10.0, 33.33, "HIGH", "negative prices: day less negative (expensive)"), (-10.0, -10.0, -10.0, 0.0, "NORMAL", "negative prices: stable"), # Transition scenarios (-10.0, -10.0, 0.0, 100.0, "HIGH", "transition: negative to zero"), (-10.0, -10.0, 10.0, 200.0, "HIGH", "transition: negative to positive"), (10.0, 10.0, 0.0, -100.0, "LOW", "transition: positive to zero"), (10.0, 10.0, -10.0, -200.0, "LOW", "transition: positive to negative"), # Zero scenarios (0.1, 0.1, 0.1, 0.0, "NORMAL", "prices near zero: stable"), ], ) def test_enrich_price_info_scenarios( # noqa: PLR0913 # Many parameters needed for comprehensive test scenarios day_before_yesterday_price: float, yesterday_price: float, today_price: float, expected_diff: float, expected_rating: str, description: str, ) -> None: """ Test price enrichment across various price scenarios. CRITICAL: Tests now include day_before_yesterday data to provide full 24h lookback for yesterday intervals. This matches the real API structure (192 intervals from priceInfoRange + today/tomorrow). """ base = datetime(2025, 11, 22, 0, 0, 0, tzinfo=UTC) time_service = TibberPricesTimeService() # Day before yesterday (needed for lookback) day_before_yesterday = [ {"startsAt": base - timedelta(days=2) + timedelta(minutes=15 * i), "total": day_before_yesterday_price} for i in range(96) ] # Yesterday (will be enriched using day_before_yesterday for lookback) yesterday = [ {"startsAt": base - timedelta(days=1) + timedelta(minutes=15 * i), "total": yesterday_price} for i in range(96) ] # Today (will be enriched using yesterday for lookback) today = [{"startsAt": base + timedelta(minutes=15 * i), "total": today_price} for i in range(96)] # Flat list matching API structure (priceInfoRange + today) all_intervals = day_before_yesterday + yesterday + today enriched = enrich_price_info_with_differences(all_intervals, time=time_service) # First "today" interval is at index 192 (96 day_before_yesterday + 96 yesterday) first_today = enriched[192] assert "difference" in first_today, f"Failed for {description}: no difference field" assert first_today["difference"] == pytest.approx(expected_diff, rel=0.01), f"Failed for {description}" assert first_today["rating_level"] == expected_rating, f"Failed for {description}" def test_enrich_price_info_no_yesterday_data() -> None: """Test enrichment when no lookback data available.""" base = datetime(2025, 11, 22, 0, 0, 0, tzinfo=UTC) time_service = TibberPricesTimeService() today = [{"startsAt": base + timedelta(minutes=15 * i), "total": 10.0} for i in range(96)] # New API: flat list (no yesterday data) all_intervals = today enriched = enrich_price_info_with_differences(all_intervals, time=time_service) # First interval has no 24h lookback → difference=None first_today = enriched[0] assert first_today.get("difference") is None assert first_today.get("rating_level") is None def test_enrich_price_info_custom_thresholds() -> None: """ Test enrichment with custom rating thresholds. CRITICAL: Includes day_before_yesterday for full 24h lookback. """ base = datetime(2025, 11, 22, 0, 0, 0, tzinfo=UTC) time_service = TibberPricesTimeService() # Day before yesterday (needed for lookback) day_before_yesterday = [ {"startsAt": base - timedelta(days=2) + timedelta(minutes=15 * i), "total": 10.0} for i in range(96) ] # Yesterday (provides lookback for today) yesterday = [{"startsAt": base - timedelta(days=1) + timedelta(minutes=15 * i), "total": 10.0} for i in range(96)] # Today (+10% vs yesterday average) today = [ {"startsAt": base + timedelta(minutes=15 * i), "total": 11.0} # +10% vs yesterday for i in range(96) ] # Flat list matching API structure all_intervals = day_before_yesterday + yesterday + today # Custom thresholds: LOW at -5%, HIGH at +5% enriched = enrich_price_info_with_differences( all_intervals, threshold_low=-5.0, threshold_high=5.0, time=time_service, ) # First "today" interval is at index 192 (96 day_before_yesterday + 96 yesterday) first_today = enriched[192] assert first_today["difference"] == pytest.approx(10.0, rel=1e-9) assert first_today["rating_level"] == "HIGH" # ============================================================================= # Price Level Aggregation (Median) - Parametrized # ============================================================================= @pytest.mark.parametrize( ("levels", "expected", "description"), [ (["CHEAP"], "CHEAP", "single level"), (["NORMAL", "NORMAL", "NORMAL"], "NORMAL", "identical levels"), (["VERY_CHEAP", "CHEAP", "NORMAL"], "CHEAP", "median of 3 levels"), (["VERY_CHEAP", "CHEAP", "NORMAL", "EXPENSIVE"], "NORMAL", "median of 4 levels (upper-middle)"), (["VERY_CHEAP", "VERY_EXPENSIVE", "NORMAL"], "NORMAL", "mixed extremes"), ([], "NORMAL", "empty list (default)"), ], ) def test_aggregate_price_levels( levels: list[str], expected: str, description: str, ) -> None: """Test price level aggregation using median.""" result = aggregate_price_levels(levels) assert result == expected, f"Failed for {description}" # ============================================================================= # Price Rating Aggregation (Average) - Parametrized # ============================================================================= @pytest.mark.parametrize( ("differences", "expected_rating", "expected_avg", "description"), [ ([15.0], "HIGH", 15.0, "single HIGH difference"), ([15.0, 20.0, 18.0], "HIGH", 17.67, "multiple HIGH differences"), ([15.0, -15.0], "NORMAL", 0.0, "mixed averaging to NORMAL"), ([-15.0, -20.0, -18.0], "LOW", -17.67, "multiple LOW differences"), ([], "NORMAL", 0.0, "empty list (default)"), ], ) def test_aggregate_price_rating( differences: list[float], expected_rating: str, expected_avg: float, description: str, ) -> None: """Test price rating aggregation using average difference.""" rating, avg_diff = aggregate_price_rating(differences, threshold_low=-10.0, threshold_high=10.0) assert rating == expected_rating, f"Failed for {description}: rating" assert avg_diff == pytest.approx(expected_avg, rel=0.01), f"Failed for {description}: avg" def test_aggregate_price_rating_with_none_values() -> None: """Test rating aggregation filtering out None values.""" rating, avg_diff = aggregate_price_rating( [15.0, None, 20.0, None, 18.0], # type: ignore[list-item] threshold_low=-10.0, threshold_high=10.0, ) assert rating == "HIGH" assert avg_diff == pytest.approx(17.67, rel=0.01) # ============================================================================= # Period Aggregation Integration # ============================================================================= def test_aggregate_period_levels_from_intervals() -> None: """Test period level aggregation from interval data.""" intervals = [ {"level": "VERY_CHEAP"}, {"level": "CHEAP"}, {"level": "NORMAL"}, ] result = aggregate_period_levels(intervals) assert result == "cheap" # Lowercase output def test_aggregate_period_ratings_from_intervals() -> None: """Test period rating aggregation from interval data.""" intervals = [ {"difference": 15.0}, {"difference": -20.0}, {"difference": 18.0}, ] rating, avg_diff = aggregate_period_ratings(intervals, threshold_low=-10.0, threshold_high=10.0) # Average: (15 - 20 + 18) / 3 = 13 / 3 ≈ 4.33 → NORMAL assert rating == "normal" assert avg_diff == pytest.approx(4.33, rel=0.01) def test_aggregate_period_ratings_no_data() -> None: """Test period rating with no valid data.""" intervals = [ {"other_field": "value"}, # No difference field {"difference": None}, # None value ] rating, avg_diff = aggregate_period_ratings(intervals, threshold_low=-10.0, threshold_high=10.0) assert rating is None assert avg_diff is None