hass.tibber_prices/tests/test_minmax_none_fallback.py
Julian Pawlowski 9c3c094305 fix(calculations): handle negative electricity prices correctly
Fixed multiple calculation issues with negative prices (Norway/Germany
renewable surplus scenarios):

Bug #6: Rating threshold validation with dead code
- Added threshold validation (low >= high) with warning
- Returns NORMAL as fallback for misconfigured thresholds

Bug #7: Min/Max functions returning 0.0 instead of None
- Changed default from 0.0 to None when window is empty
- Prevents misinterpretation (0.0 looks like price with negatives)

Bug #9: Period price diff percentage wrong sign with negative reference
- Use abs(ref_price) in percentage calculation
- Correct percentage direction for negative prices

Bug #10: Trend diff percentage wrong sign with negative current price
- Use abs(current_interval_price) in percentage calculation
- Correct trend direction when prices cross zero

Bug #11: later_half_diff calculation failed for negative prices
- Changed condition from `if current_interval_price > 0` to `!= 0`
- Use abs(current_interval_price) for percentage

Changes:
- utils/price.py: Add threshold validation, use abs() in percentages
- utils/average.py: Return None instead of 0.0 for empty windows
- period_statistics.py: Use abs() for reference prices
- trend.py: Use abs() for current prices, fix zero-check condition
- tests: 95+ new tests covering negative/zero/mixed price scenarios

Impact: All calculations work correctly with negative electricity prices.
Percentages show correct direction regardless of sign.
2025-11-22 04:45:23 +00:00

198 lines
7.1 KiB
Python

"""Tests for Bug #7: Min/Max functions returning 0.0 instead of None."""
from datetime import UTC, datetime, timedelta
import pytest
from custom_components.tibber_prices.coordinator.time_service import (
TibberPricesTimeService,
)
from custom_components.tibber_prices.utils.average import (
calculate_leading_24h_max,
calculate_leading_24h_min,
calculate_trailing_24h_max,
calculate_trailing_24h_min,
)
@pytest.fixture
def time_service() -> TibberPricesTimeService:
"""Create a TibberPricesTimeService instance for testing."""
return TibberPricesTimeService()
def test_trailing_24h_min_with_empty_window(time_service: TibberPricesTimeService) -> None:
"""
Test that trailing min returns None when no data in window.
Bug #7: Previously returned 0.0, which could be misinterpreted as
a maximum value with negative prices.
"""
# Data exists, but outside the 24h window
old_data = [
{
"startsAt": datetime(2025, 11, 1, 10, 0, tzinfo=UTC), # 20 days ago
"total": -15.0, # Negative price
}
]
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC) # Today
# Should return None (no data in window), not 0.0
result = calculate_trailing_24h_min(old_data, interval_start, time=time_service)
assert result is None
def test_trailing_24h_max_with_empty_window(time_service: TibberPricesTimeService) -> None:
"""Test that trailing max returns None when no data in window."""
old_data = [
{
"startsAt": datetime(2025, 11, 1, 10, 0, tzinfo=UTC), # 20 days ago
"total": -15.0,
}
]
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC)
result = calculate_trailing_24h_max(old_data, interval_start, time=time_service)
assert result is None
def test_leading_24h_min_with_empty_window(time_service: TibberPricesTimeService) -> None:
"""Test that leading min returns None when no data in window."""
old_data = [
{
"startsAt": datetime(2025, 11, 1, 10, 0, tzinfo=UTC), # Past data
"total": -15.0,
}
]
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC) # Today - no future data
result = calculate_leading_24h_min(old_data, interval_start, time=time_service)
assert result is None
def test_leading_24h_max_with_empty_window(time_service: TibberPricesTimeService) -> None:
"""Test that leading max returns None when no data in window."""
old_data = [
{
"startsAt": datetime(2025, 11, 1, 10, 0, tzinfo=UTC), # Past data
"total": -15.0,
}
]
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC)
result = calculate_leading_24h_max(old_data, interval_start, time=time_service)
assert result is None
def test_trailing_24h_min_with_negative_prices(time_service: TibberPricesTimeService) -> None:
"""
Test trailing min with negative prices returns actual minimum, not 0.0.
This demonstrates why Bug #7 was critical: with negative prices,
0.0 would appear to be a maximum, not an error indicator.
"""
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC)
window_start = interval_start - timedelta(hours=24)
# All negative prices
data = [{"startsAt": window_start + timedelta(hours=i), "total": -10.0 - i} for i in range(24)]
result = calculate_trailing_24h_min(data, interval_start, time=time_service)
# Should return actual minimum (-33.0), not 0.0
assert result == -33.0
assert result != 0.0 # Emphasize this was the bug
def test_trailing_24h_max_with_negative_prices(time_service: TibberPricesTimeService) -> None:
"""Test trailing max with negative prices."""
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC)
window_start = interval_start - timedelta(hours=24)
# All negative prices
data = [{"startsAt": window_start + timedelta(hours=i), "total": -10.0 - i} for i in range(24)]
result = calculate_trailing_24h_max(data, interval_start, time=time_service)
# Maximum of negative numbers is least negative
assert result == -10.0
def test_trailing_24h_min_distinguishes_zero_from_none(
time_service: TibberPricesTimeService,
) -> None:
"""
Test that function distinguishes between 0.0 price and no data.
Bug #7: Previously, both cases returned 0.0, making them indistinguishable.
"""
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC)
window_start = interval_start - timedelta(hours=24)
# Case 1: Price is actually 0.0
data_with_zero = [{"startsAt": window_start + timedelta(hours=i), "total": 0.0 + i} for i in range(24)]
result_with_zero = calculate_trailing_24h_min(data_with_zero, interval_start, time=time_service)
assert result_with_zero == 0.0 # Actual price
# Case 2: No data in window
empty_data: list[dict] = []
result_no_data = calculate_trailing_24h_min(empty_data, interval_start, time=time_service)
assert result_no_data is None # No data
# CRITICAL: These must be distinguishable!
assert result_with_zero != result_no_data
def test_trailing_24h_functions_with_partial_window(
time_service: TibberPricesTimeService,
) -> None:
"""
Test that functions work correctly with partial 24h window.
This tests the edge case where data exists but doesn't cover full 24h.
"""
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC)
window_start = interval_start - timedelta(hours=24)
# Only 12 hours of data (half the window)
data = [{"startsAt": window_start + timedelta(hours=i), "total": float(i)} for i in range(12)]
result_min = calculate_trailing_24h_min(data, interval_start, time=time_service)
result_max = calculate_trailing_24h_max(data, interval_start, time=time_service)
# Should calculate based on available data
assert result_min == 0.0
assert result_max == 11.0
def test_leading_24h_functions_with_negative_and_positive_mix(
time_service: TibberPricesTimeService,
) -> None:
"""Test leading functions with mix of negative and positive prices."""
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC)
# Mix of negative and positive prices
data = [{"startsAt": interval_start + timedelta(hours=i), "total": -10.0 + i} for i in range(24)]
result_min = calculate_leading_24h_min(data, interval_start, time=time_service)
result_max = calculate_leading_24h_max(data, interval_start, time=time_service)
assert result_min == -10.0 # Most negative
assert result_max == 13.0 # Most positive
def test_empty_price_list_returns_none(time_service: TibberPricesTimeService) -> None:
"""Test that all functions return None with completely empty price list."""
interval_start = datetime(2025, 11, 21, 14, 0, tzinfo=UTC)
empty_data: list[dict] = []
assert calculate_trailing_24h_min(empty_data, interval_start, time=time_service) is None
assert calculate_trailing_24h_max(empty_data, interval_start, time=time_service) is None
assert calculate_leading_24h_min(empty_data, interval_start, time=time_service) is None
assert calculate_leading_24h_max(empty_data, interval_start, time=time_service) is None