Add comprehensive period calculation tests

Co-authored-by: jpawlowski <75446+jpawlowski@users.noreply.github.com>
This commit is contained in:
copilot-swe-agent[bot] 2025-12-25 00:03:07 +00:00
parent cf01e01cca
commit 9f4088623a
2 changed files with 481 additions and 0 deletions

View file

@ -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"

View file

@ -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."
)